Skip to main content

runner_manager_agent/
lifecycle.rs

1// owner: e3-jit-lifecycle-recovery
2
3//! One ephemeral runner, from an allocation decision to a scrubbed runtime.
4//!
5//! The ordering in this module is intentional.  An attempt is written before
6//! the package or GitHub is touched, the JIT value exists only in a restrictive
7//! handoff, and a registration-timeout termination is journalled before the
8//! process is signalled.  Recovery uses the same code as ordinary supervision;
9//! startup merely supplies the first observation.
10
11#[cfg(test)]
12use std::collections::VecDeque;
13use std::collections::{BTreeMap, BTreeSet, HashMap};
14use std::ffi::OsStr;
15use std::fmt;
16use std::fs;
17use std::io::Write;
18use std::num::NonZeroU16;
19use std::path::{Path, PathBuf};
20use std::sync::{Arc, Mutex};
21use std::time::Duration;
22
23use async_trait::async_trait;
24use runner_manager_domain::attempt::{
25    AttemptOutcome, AttemptState, FailureReason, GithubRunnerObservation, IsolationProviderFailure,
26    RecoveryDecision, RecoveryObservation, RecoveryTimeouts, RunnerAttempt, authorize,
27    recovery_decision,
28};
29use runner_manager_domain::execution::{AttemptExecution, Backend, ImageReference};
30use runner_manager_domain::model::{AttemptId, Clock, HostId, PolicyId, ScaleTarget};
31use runner_manager_domain::path::LocalAbsolutePath;
32use runner_manager_domain::policy::ScalePolicy;
33use runner_manager_domain::store::{Store, StoreError};
34use runner_manager_domain::workspace::{AttemptWorkspace, WorkspacePolicy};
35use runner_manager_github::jit::{
36    DEFAULT_WORK_FOLDER, EncodedJitConfig, JitError, JitGateway, JitRegistration, JitRunnerRequest,
37};
38use runner_manager_github::rest::{CancelToken, InventoryGateway};
39use runner_manager_platform::process::{
40    Adoption, ChildProcess, ProcessIdentity, RestrictiveHandoff, SpawnSpec, Termination,
41};
42use runner_manager_platform::runner_env::{self, RunnerEnv, RunnerPlatform};
43use runner_manager_platform::runner_root::{
44    self, RootOwner, RootPreflight, RunnerRootError, default_runner_root,
45};
46use secrecy::SecretString;
47
48use crate::dependency_caches::DependencyCaches;
49use crate::oci::OciProcesses;
50use crate::package::{
51    InstalledPackage, LastCheck, PackageCache, PackageContents, PackageError, RunnerVersion,
52};
53use crate::reconcile::{
54    AllocationGuard, CleanBackoff, EventSink, LaunchFailure, LaunchRequest, LifecycleEvent,
55    OutcomeKind, ReplacementIntent, ReservationWaits, RunnerLauncher, failure_reason_kind,
56};
57
58mod windows_hyperv;
59pub use windows_hyperv::{WindowsHyperVContainers, WindowsHyperVHostState};
60
61const IDENTITY_FILE: &str = ".runner-process.json";
62const FALLBACK_IDENTITY_FILE: &str = ".runner-process.recovery.json";
63const UNRESOLVED_PROCESS_FILE: &str = ".runner-process.unresolved";
64const RUNNER_ID_FILE: &str = ".github-runner-id";
65const TERMINATE_INTENT_FILE: &str = ".terminate-registration-timeout";
66const MAX_POST_SPAWN_STOP_ATTEMPTS: usize = 3;
67
68/// Slot-root names a cleaned persistent attempt must not have left behind.
69///
70/// This is not the rule — the rule is that *nothing* but a real `_work`
71/// survives, and [`verify_slot_scrubbed`] enforces that by counting. This list
72/// is the second, independent question asked of the same directory: each name
73/// is stat-ed directly, so a scrub that skipped one is caught even if the
74/// enumeration that was supposed to find it under-reported. Every entry is one
75/// of the things `04-security-recovery.md` requires to be proven absent before a
76/// slot is released; the encoded JIT handoff is the one exception, matched by
77/// its published prefix in [`verify_slot_scrubbed`] because the rest of its name
78/// is a UUID. Being compile-time constants, these are also the only entry names
79/// a refusal message is allowed to print.
80const SENSITIVE_SLOT_ENTRIES: &[&str] = &[
81    // Runner binaries and the launchers beside them.
82    "bin",
83    "externals",
84    "run.sh",
85    "run.cmd",
86    "config.sh",
87    "config.cmd",
88    // The registration identity GitHub's runner writes for itself, and the
89    // per-run environment it reads back.
90    ".runner",
91    ".credentials",
92    ".credentials_rsaparams",
93    ".env",
94    ".path",
95    "_diag",
96    // This agent's own process-identity and lifecycle sidecars.
97    IDENTITY_FILE,
98    FALLBACK_IDENTITY_FILE,
99    UNRESOLVED_PROCESS_FILE,
100    RUNNER_ID_FILE,
101    TERMINATE_INTENT_FILE,
102];
103#[cfg(test)]
104const TEST_LISTENER_READY: &str = ".test-listener-ready";
105
106/// GitHub Runner v2.336.0 accepts JIT configuration for `run` through its
107/// secret `ACTIONS_RUNNER_INPUT_JITCONFIG` input. The platform spawn boundary
108/// supplies that input from the restrictive handoff; the listener command line
109/// must contain only the supported `run` command.
110///
111/// The environment is the daemon's plus [`runner_env::platform_defaults`], then
112/// the dependency caches (`caches`, from [`DependencyCaches::for_launch`]), then
113/// the host's `runner.env`, which wins over either of the others.
114/// The per-attempt temporary directory goes last and `runner.env` cannot name
115/// it. On macOS a runner that still starts throttled is reported
116/// ([`SpawnSpec::expect_normal_scheduling`]).
117fn runner_listener_spec(
118    program: PathBuf,
119    runtime: &Path,
120    caches: Vec<(&'static str, std::ffi::OsString)>,
121    host_env: &RunnerEnv,
122) -> SpawnSpec {
123    let tmp = runtime.join("tmp");
124    let _ = std::fs::create_dir_all(&tmp);
125    let defaults = runner_env::platform_defaults(
126        RunnerPlatform::current(),
127        runtime,
128        &runner_env::Inherited::current(),
129        Path::exists,
130    );
131    // The Windows profile, or the Linux home, the defaults point at. Created
132    // even when `runner.env` overrides part of it: it is inside the attempt, so
133    // it costs nothing and goes with the attempt.
134    for (name, dir) in &defaults {
135        if runner_env::DIRECTORY_VARIABLES.contains(name) {
136            let _ = std::fs::create_dir_all(dir);
137        }
138    }
139    let mut spec = SpawnSpec::new(program)
140        .arg("run")
141        .working_dir(runtime)
142        .expect_normal_scheduling();
143    for (name, value) in runner_env::runner_environment(defaults, caches, host_env) {
144        spec = spec.env(name, value);
145    }
146    spec.env("TMPDIR", &tmp).env("TEMP", &tmp).env("TMP", &tmp)
147}
148
149/// Retry bounds for failures that can resolve without operator action.
150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
151pub struct RetryPolicy {
152    pub max_attempts: u32,
153    pub initial: Duration,
154    pub maximum: Duration,
155}
156
157impl RetryPolicy {
158    #[must_use]
159    pub const fn bounded(max_attempts: u32, initial: Duration, maximum: Duration) -> Self {
160        Self {
161            max_attempts,
162            initial,
163            maximum,
164        }
165    }
166
167    pub(crate) fn delay(self, failure_index: u32) -> Duration {
168        let shift = failure_index.saturating_sub(1).min(31);
169        self.initial
170            .saturating_mul(1_u32 << shift)
171            .min(self.maximum)
172    }
173}
174
175/// Non-secret lifecycle evidence.  Payloads are identifiers and closed enums;
176/// neither the encoded configuration nor child output can enter this type.
177#[derive(Debug, Clone, PartialEq, Eq)]
178pub enum AttemptEvent {
179    State {
180        attempt: AttemptId,
181        state: AttemptState,
182    },
183    Retry {
184        attempt: AttemptId,
185        operation: &'static str,
186        delay: Duration,
187    },
188    /// How long each step of a launch took, whether or not it started a
189    /// runner, so a slow or failed start can be attributed to the wait for the
190    /// allocation lock, the package copy, the JIT request, or the process start.
191    Launched {
192        attempt: AttemptId,
193        started: bool,
194        timings: LaunchTimings,
195    },
196    Adopted {
197        attempt: AttemptId,
198    },
199    RemoteIdentityRecovered {
200        attempt: AttemptId,
201        runner_id: u64,
202    },
203    TerminateIntent {
204        attempt: AttemptId,
205    },
206    Terminated {
207        attempt: AttemptId,
208    },
209    /// The attempt's GitHub registration was removed by this agent. Carries the
210    /// runner id because that is the identifier an operator sees in the
211    /// target's runner settings, and the attempt id is not shown there.
212    Deregistered {
213        attempt: AttemptId,
214        runner_id: u64,
215    },
216    Concluded {
217        attempt: AttemptId,
218        outcome: OutcomeKind,
219    },
220    Cleaned {
221        attempt: AttemptId,
222        outcome: OutcomeKind,
223    },
224    QuarantinedOwnedResource {
225        attempt: AttemptId,
226    },
227}
228
229pub trait AttemptEventSink: fmt::Debug + Send + Sync {
230    fn emit(&self, event: AttemptEvent);
231}
232
233#[derive(Debug, Default)]
234pub struct AttemptEventLog(Mutex<Vec<AttemptEvent>>);
235
236impl AttemptEventLog {
237    #[must_use]
238    pub fn events(&self) -> Vec<AttemptEvent> {
239        self.0
240            .lock()
241            .map(|events| events.clone())
242            .unwrap_or_default()
243    }
244}
245
246impl AttemptEventSink for AttemptEventLog {
247    fn emit(&self, event: AttemptEvent) {
248        if let Ok(mut events) = self.0.lock() {
249            events.push(event);
250        }
251    }
252}
253
254#[derive(Debug, Clone, Copy, Default)]
255pub struct NoAttemptEvents;
256
257impl AttemptEventSink for NoAttemptEvents {
258    fn emit(&self, _event: AttemptEvent) {}
259}
260
261/// Where one launch spent its time. A step the launch never reached is zero.
262#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
263pub struct LaunchTimings {
264    /// Waiting for older launches of this host to reserve first.
265    pub order_wait: Duration,
266    /// Waiting for the host allocation lock, which another launch may hold, and
267    /// for the rest of its pass to be reserved after it.
268    pub lock_wait: Duration,
269    /// Waiting for a turn to prepare: see [`preparation_turns`].
270    pub turn_wait: Duration,
271    pub materialize: Duration,
272    /// Where `materialize` went.
273    pub package: PackageTimings,
274    /// The execution provider's preparation: its environment, and the checks
275    /// of the host's runner environment and dependency caches.
276    pub prepare: Duration,
277    pub register: Duration,
278    pub spawn: Duration,
279}
280
281impl LaunchTimings {
282    #[must_use]
283    pub fn total(&self) -> Duration {
284        self.order_wait
285            + self.lock_wait
286            + self.turn_wait
287            + self.materialize
288            + self.prepare
289            + self.register
290            + self.spawn
291    }
292}
293
294/// Where a launch's package step went, across its retries.
295#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
296pub struct PackageTimings {
297    /// Waiting for another launch's install, or for copies an install must
298    /// not run under.
299    pub wait: Duration,
300    /// Checking the catalog, and downloading and extracting a package.
301    pub install: Duration,
302    /// Fingerprinting the trees a copy may trust.
303    pub verify: Duration,
304    /// Copying or cloning the tree into the runner's runtime.
305    pub copy: Duration,
306    /// A package was downloaded for this launch.
307    pub downloaded: bool,
308}
309
310#[derive(Debug, Clone, Copy)]
311enum LaunchStep {
312    Turn,
313    Materialize,
314    Prepare,
315    Register,
316    Spawn,
317}
318
319/// Times a launch's steps; beginning one ends the one before, so an early
320/// return still leaves the step it failed in measured once [`Self::finish`]
321/// runs.
322#[derive(Debug)]
323struct LaunchStopwatch {
324    timings: LaunchTimings,
325    running: Option<(LaunchStep, std::time::Instant)>,
326}
327
328impl LaunchStopwatch {
329    fn new(waits: ReservationWaits) -> Self {
330        Self {
331            timings: LaunchTimings {
332                order_wait: waits.order,
333                lock_wait: waits.lock,
334                ..LaunchTimings::default()
335            },
336            running: None,
337        }
338    }
339
340    fn begin(&mut self, step: LaunchStep) {
341        self.finish();
342        self.running = Some((step, std::time::Instant::now()));
343    }
344
345    fn finish(&mut self) {
346        if let Some((step, since)) = self.running.take() {
347            let slot = match step {
348                LaunchStep::Turn => &mut self.timings.turn_wait,
349                LaunchStep::Materialize => &mut self.timings.materialize,
350                LaunchStep::Prepare => &mut self.timings.prepare,
351                LaunchStep::Register => &mut self.timings.register,
352                LaunchStep::Spawn => &mut self.timings.spawn,
353            };
354            *slot = since.elapsed();
355        }
356    }
357}
358
359/// A launch slower than this is logged as a warning, with its steps.
360///
361/// A healthy launch is a few seconds; a queued job waits for all of it, and
362/// the daemon's default filter drops the `info` line that times every launch.
363const SLOW_LAUNCH: Duration = Duration::from_secs(60);
364
365/// The daemon's sink for retries and launch timings; the other attempt events
366/// already reach the log through the reconcile sink.
367///
368/// Every field name is on `d1`'s allow-list, or the redacting layer would log
369/// `[redacted]` in its place; `tests::every_field_name_the_attempt_sink_emits_is_allowed`
370/// keeps that true.
371#[derive(Debug, Clone, Copy, Default)]
372pub struct TracingAttemptEvents;
373
374impl AttemptEventSink for TracingAttemptEvents {
375    fn emit(&self, event: AttemptEvent) {
376        match event {
377            AttemptEvent::Retry {
378                attempt,
379                operation,
380                delay,
381            } => tracing::warn!(
382                event = "attempt_retry",
383                attempt_id = %attempt,
384                reason = operation,
385                retry_in_ms = millis(delay),
386            ),
387            AttemptEvent::Launched {
388                attempt,
389                started,
390                timings,
391            } => {
392                macro_rules! launched {
393                    ($level:ident, $name:literal) => {
394                        tracing::$level!(
395                            event = $name,
396                            attempt_id = %attempt,
397                            duration_ms = millis(timings.total()),
398                            order_wait_ms = millis(timings.order_wait),
399                            lock_wait_ms = millis(timings.lock_wait),
400                            turn_wait_ms = millis(timings.turn_wait),
401                            materialize_ms = millis(timings.materialize),
402                            package_wait_ms = millis(timings.package.wait),
403                            package_install_ms = millis(timings.package.install),
404                            package_verify_ms = millis(timings.package.verify),
405                            package_copy_ms = millis(timings.package.copy),
406                            package_downloaded = timings.package.downloaded,
407                            prepare_ms = millis(timings.prepare),
408                            register_ms = millis(timings.register),
409                            spawn_ms = millis(timings.spawn),
410                        )
411                    };
412                }
413                if !started {
414                    launched!(warn, "attempt_launch_failed");
415                } else if timings.total() >= SLOW_LAUNCH {
416                    launched!(warn, "attempt_launch_slow");
417                } else {
418                    launched!(info, "attempt_launched");
419                }
420            }
421            _ => {}
422        }
423    }
424}
425
426fn millis(duration: Duration) -> u64 {
427    u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
428}
429
430/// Whether the demand that justified a retry still exists.
431#[async_trait]
432pub trait DemandPersistence: fmt::Debug + Send + Sync {
433    async fn persists(&self, policy: PolicyId) -> bool;
434}
435
436#[derive(Debug, Clone, Copy, Default)]
437pub struct PersistentDemand;
438
439#[async_trait]
440impl DemandPersistence for PersistentDemand {
441    async fn persists(&self, _policy: PolicyId) -> bool {
442        true
443    }
444}
445
446#[async_trait]
447pub trait RetryDelay: fmt::Debug + Send + Sync {
448    async fn wait(&self, duration: Duration);
449}
450
451#[derive(Debug, Clone, Copy, Default)]
452pub struct TokioRetryDelay;
453
454#[async_trait]
455impl RetryDelay for TokioRetryDelay {
456    async fn wait(&self, duration: Duration) {
457        tokio::time::sleep(duration).await;
458    }
459}
460
461#[derive(Debug, Clone, PartialEq, Eq)]
462pub struct JitRequestFailure {
463    pub terminal: bool,
464    pub reason: FailureReason,
465    pub retry_after: Option<Duration>,
466}
467
468/// GitHub's authoritative runner state plus the identity returned by inventory.
469/// The id is carried independently of the local sidecar so recovery can close
470/// the crash boundary immediately after a successful remote registration.
471#[derive(Debug, Clone, Copy, PartialEq, Eq)]
472pub struct LifecycleGithubObservation {
473    pub status: GithubRunnerObservation,
474    pub runner_id: Option<u64>,
475}
476
477impl LifecycleGithubObservation {
478    #[must_use]
479    pub const fn unreachable() -> Self {
480        Self {
481            status: GithubRunnerObservation::Unreachable,
482            runner_id: None,
483        }
484    }
485
486    #[must_use]
487    pub const fn not_registered() -> Self {
488        Self {
489            status: GithubRunnerObservation::NotRegistered,
490            runner_id: None,
491        }
492    }
493
494    #[must_use]
495    pub const fn registered(runner_id: u64, busy: bool) -> Self {
496        Self {
497            status: GithubRunnerObservation::Registered { busy },
498            runner_id: Some(runner_id),
499        }
500    }
501}
502
503/// The two GitHub views the lifecycle needs, combined so one fake can drive
504/// registration and authoritative runner telemetry.
505#[async_trait]
506pub trait LifecycleGithub: fmt::Debug + Send + Sync {
507    async fn register(
508        &self,
509        target: &ScaleTarget,
510        request: &JitRunnerRequest,
511        cancel: &CancelToken,
512    ) -> Result<JitRegistration, JitRequestFailure>;
513
514    async fn observe(
515        &self,
516        target: &ScaleTarget,
517        attempt: AttemptId,
518        cancel: &CancelToken,
519    ) -> LifecycleGithubObservation;
520
521    /// Remove one runner registration this agent created.
522    ///
523    /// Answers whether the registration is gone, and is deliberately not
524    /// fallible in the `Result` sense: no caller may abandon a conclusion
525    /// because GitHub was unreachable. See
526    /// [`LifecycleLauncher::deregister_runner`].
527    async fn deregister(&self, target: &ScaleTarget, runner_id: u64, cancel: &CancelToken) -> bool;
528}
529
530#[async_trait]
531impl<T> LifecycleGithub for T
532where
533    T: JitGateway + InventoryGateway + fmt::Debug + Send + Sync,
534{
535    async fn register(
536        &self,
537        target: &ScaleTarget,
538        request: &JitRunnerRequest,
539        cancel: &CancelToken,
540    ) -> Result<JitRegistration, JitRequestFailure> {
541        self.generate_jit_config(target, request, cancel)
542            .await
543            .map_err(|error| {
544                let reason = if matches!(&error, JitError::Forbidden { .. }) {
545                    FailureReason::Other(
546                        "GitHub refused JIT registration with 403; check the App's runner permission and runner-group access"
547                            .into(),
548                    )
549                } else {
550                    FailureReason::JitRequestFailed
551                };
552                JitRequestFailure {
553                    terminal: error.is_terminal(),
554                    reason,
555                    retry_after: error
556                        .rate_limited()
557                        .map(|limit| limit.delay_from(self.now())),
558                }
559            })
560    }
561
562    async fn observe(
563        &self,
564        target: &ScaleTarget,
565        attempt: AttemptId,
566        cancel: &CancelToken,
567    ) -> LifecycleGithubObservation {
568        let expected_name = runner_name(attempt);
569        match self.list_runners(target, cancel).await {
570            Ok(inventory) => inventory
571                .runners()
572                .iter()
573                .find(|runner| runner.name == expected_name)
574                .map_or(LifecycleGithubObservation::not_registered(), |runner| {
575                    LifecycleGithubObservation::registered(runner.id, runner.busy)
576                }),
577            Err(_) => LifecycleGithubObservation::unreachable(),
578        }
579    }
580
581    async fn deregister(&self, target: &ScaleTarget, runner_id: u64, cancel: &CancelToken) -> bool {
582        self.remove_runner(target, runner_id, cancel).await.is_ok()
583    }
584}
585
586/// Package/cache operations used by one attempt.
587#[async_trait]
588pub trait RuntimePackages: fmt::Debug + Send + Sync {
589    /// Copy the package into `attempt`'s runtime, adding where the time went
590    /// to `timings`.
591    async fn materialize(
592        &self,
593        attempt: &RunnerAttempt,
594        timings: &mut PackageTimings,
595    ) -> Result<RunnerVersion, FailureReason>;
596    fn release(&self, attempt: AttemptId) -> Result<(), FailureReason>;
597    fn prune_obsolete_guarded(
598        &self,
599        authority: PruneAuthority<'_>,
600        attempts: &[RunnerAttempt],
601    ) -> Result<(), FailureReason>;
602}
603
604/// Unforgeable evidence that pruning was reached through e1's launch request.
605/// The type is public only because it appears in the public adapter trait; its
606/// private field and constructor prevent callers from substituting a guard
607/// acquired from an unrelated lock.
608pub struct PruneAuthority<'a> {
609    _guard: &'a AllocationGuard,
610}
611
612impl fmt::Debug for PruneAuthority<'_> {
613    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
614        f.write_str("PruneAuthority")
615    }
616}
617
618impl<'a> PruneAuthority<'a> {
619    fn from_launch_request(guard: &'a AllocationGuard) -> Self {
620        Self { _guard: guard }
621    }
622
623    #[cfg(test)]
624    pub(crate) const fn for_test(guard: &'a AllocationGuard) -> Self {
625        Self { _guard: guard }
626    }
627}
628
629/// What every target of one daemon shares about the one package directory they
630/// all use: which trees this process vouches for, and who may touch them.
631///
632/// Per target, one target's reinstall replaces the tree another target
633/// recorded, whose next launch finds it changed and reinstalls in turn: on a
634/// host serving two targets, every switch between them was a full download.
635#[derive(Debug, Clone, Default)]
636pub struct SharedPackages {
637    /// Held shared to copy from a tree, exclusive to change one. A cache entry
638    /// or seed is not replaced under a copy.
639    trees: Arc<tokio::sync::RwLock<()>>,
640    trusted: Arc<TrustedTrees>,
641    last_check: LastCheck,
642    /// The last install that failed, and when: the launches queued behind it
643    /// get its answer. See [`CachedRuntimePackages::materialize`].
644    failed_install: Arc<Mutex<Option<(std::time::Instant, FailureReason)>>>,
645}
646
647impl SharedPackages {
648    /// Stops trusting a shared package, unless a later install has replaced the
649    /// record this launch read: that one is another package's. The next launch
650    /// prepares it again.
651    fn discard(
652        &self,
653        root: &Path,
654        origin: &Path,
655        contents: &Arc<PackageContents>,
656        reason: &'static str,
657    ) {
658        if self.trusted.forget_if_holding(root, contents) {
659            tracing::warn!(
660                event = "package_verify_failed",
661                reason,
662                "a shared runner package does not match its verified archive; it is discarded \
663                 and prepared again"
664            );
665            self.trusted.forget(origin);
666        }
667    }
668
669    /// What an install ended in, for the launches queued behind it. A terminal
670    /// failure is not shared: it would close each of them, with no retry of its
671    /// own, and one that succeeded clears the last failure.
672    fn note_install(&self, result: Result<(), &FailureReason>) {
673        *self
674            .failed_install
675            .lock()
676            .unwrap_or_else(std::sync::PoisonError::into_inner) = result
677            .err()
678            .filter(|reason| !package_failure_is_terminal(reason))
679            .map(|reason| (std::time::Instant::now(), reason.clone()));
680    }
681
682    /// What an install that failed while a launch queued since `queued` for it
683    /// answered.
684    fn install_failed_since(&self, queued: std::time::Instant) -> Option<FailureReason> {
685        self.failed_install
686            .lock()
687            .unwrap_or_else(std::sync::PoisonError::into_inner)
688            .as_ref()
689            .filter(|(at, _)| *at >= queued)
690            .map(|(_, reason)| reason.clone())
691    }
692
693    /// For each target's [`PackageCache`]: the published version is one fact,
694    /// so one target's check spares the others theirs, and each check holds
695    /// every copy on this host back.
696    #[must_use]
697    pub fn last_check(&self) -> LastCheck {
698        self.last_check.clone()
699    }
700}
701
702#[cfg(test)]
703impl SharedPackages {
704    pub(crate) async fn copying(&self) -> tokio::sync::RwLockReadGuard<'_, ()> {
705        self.trees.read().await
706    }
707
708    /// Trusts `root`'s look as it is now, keeping what it must hold: what an edit
709    /// no fingerprint can see leaves behind, as on Windows, or by a superuser.
710    pub(crate) fn blind_to_edits_of(&self, root: &Path) {
711        let contents = self.trusted.contents_of(root);
712        self.trusted.record(root, contents).expect("recorded");
713    }
714
715    pub(crate) fn vouches_for(&self, root: &Path) -> bool {
716        self.trusted.contents_of(root).is_some()
717    }
718}
719
720/// Production package adapter.  It takes an e2 lease before returning, so a
721/// cache entry can never look unused while its runtime is starting.
722#[derive(Debug)]
723pub struct CachedRuntimePackages {
724    cache: Arc<PackageCache>,
725    shared: SharedPackages,
726}
727
728/// Add how long `work` took to `slot`.
729async fn timed<T>(slot: &mut Duration, work: impl std::future::Future<Output = T>) -> T {
730    let began = std::time::Instant::now();
731    let result = work.await;
732    *slot += began.elapsed();
733    result
734}
735
736impl CachedRuntimePackages {
737    /// Run `work` against a package tree on the blocking pool: a fingerprint
738    /// walks ~9,000 entries and a copy can take minutes.
739    async fn blocking<T: Send + 'static>(
740        &self,
741        root: &Path,
742        work: impl FnOnce(&TrustedTrees, &Path) -> T + Send + 'static,
743    ) -> Result<T, FailureReason> {
744        let trusted = Arc::clone(&self.shared.trusted);
745        let root = root.to_path_buf();
746        tokio::task::spawn_blocking(move || work(&trusted, &root))
747            .await
748            .map_err(|_| FailureReason::ProcessStartFailed)
749    }
750
751    /// One target's view of the package directory the targets of `shared` use.
752    #[must_use]
753    pub const fn new(cache: Arc<PackageCache>, shared: SharedPackages) -> Self {
754        Self { cache, shared }
755    }
756
757    /// A copy that cannot be used: removed, and the trees it came from read to see
758    /// whose fault it is. A runtime can be touched by a job, or sit on a disk with a
759    /// fault, and that is no reason to download anything. A tree that differs is
760    /// discarded, and prepared again by the next launch that needs it.
761    async fn reject_copy(
762        &self,
763        attempt: &RunnerAttempt,
764        root: &Path,
765        origin: &Path,
766        contents: &Arc<PackageContents>,
767        timings: &mut PackageTimings,
768    ) -> Result<(), FailureReason> {
769        let (attempt_copy, checking, from) =
770            (attempt.clone(), Arc::clone(contents), origin.to_path_buf());
771        let differs = timed(
772            &mut timings.verify,
773            self.blocking(root, move |_, source| {
774                let _ = remove_materialized_package(&attempt_copy);
775                let shared = checking.verify(source, |_| false);
776                shared.and_then(|()| {
777                    if from == source {
778                        Ok(())
779                    } else {
780                        checking.verify(&from, |_| false)
781                    }
782                })
783            }),
784        )
785        .await?;
786        if let Err(mismatch) = differs {
787            self.shared
788                .discard(root, origin, contents, mismatch.as_str());
789        }
790        Ok(())
791    }
792
793    /// The tree a runtime can be copied from right now, under nothing more than
794    /// the shared hold: nothing to check for or install, and a tree still as
795    /// this process left it.
796    async fn ready(
797        &self,
798        destination: &Path,
799        timings: &mut PackageTimings,
800    ) -> Result<Option<(InstalledPackage, PathBuf)>, FailureReason> {
801        let Some(installed) = self.cache.reusable().map_err(package_failure)? else {
802            return Ok(None);
803        };
804        let destination = destination.to_path_buf();
805        let verifying = self.blocking(installed.root(), move |trusted, source| {
806            if trusted.holds(source) {
807                copy_origin_ready(source, &destination, trusted)
808            } else {
809                None
810            }
811        });
812        let origin = timed(&mut timings.verify, verifying).await?;
813        Ok(origin.map(|origin| (installed, origin)))
814    }
815
816    /// Under the exclusive hold: a package installed and trusted, and the tree
817    /// to copy it from.
818    async fn install_trusted(
819        &self,
820        destination: &Path,
821        timings: &mut PackageTimings,
822    ) -> Result<(InstalledPackage, PathBuf), FailureReason> {
823        let (mut installed, downloaded) = timed(
824            &mut timings.install,
825            self.cache.ensure_installed_reporting(),
826        )
827        .await
828        .map_err(package_failure)?;
829        timings.downloaded |= downloaded;
830        // A cache entry this process did not install, or one that has changed
831        // since, may hold a job's edits; replace it with one downloaded and
832        // verified now. One this call just downloaded is trusted as it lands.
833        // See `TrustedTrees`.
834        let fresh = downloaded
835            || !timed(
836                &mut timings.verify,
837                self.blocking(installed.root(), TrustedTrees::holds),
838            )
839            .await?;
840        if fresh && !downloaded {
841            // After a restart there is simply no record. With one, this is an edit.
842            if self.shared.trusted.contents_of(installed.root()).is_some() {
843                tracing::warn!(
844                    event = "package_verify_failed",
845                    reason = "changed",
846                    "a shared runner package changed since it was prepared; it is discarded and \
847                     prepared again"
848                );
849            }
850            installed = timed(&mut timings.install, self.cache.reinstall())
851                .await
852                .map_err(package_failure)?;
853            timings.downloaded = true;
854        }
855        let destination = destination.to_path_buf();
856        let contents = installed.contents();
857        let origin = timed(
858            &mut timings.verify,
859            self.blocking(installed.root(), move |trusted, source| {
860                if fresh {
861                    trusted.record(source, contents)?;
862                }
863                Ok::<_, std::io::Error>(copy_origin(source, &destination, trusted))
864            }),
865        )
866        .await?
867        .map_err(|_| FailureReason::ProcessStartFailed)?;
868        Ok((installed, origin))
869    }
870}
871
872#[async_trait]
873impl RuntimePackages for CachedRuntimePackages {
874    async fn materialize(
875        &self,
876        attempt: &RunnerAttempt,
877        timings: &mut PackageTimings,
878    ) -> Result<RunnerVersion, FailureReason> {
879        let destination = attempt.runtime_path().to_path_buf();
880        let trees = &self.shared.trees;
881        // Only a launch that has something to change goes exclusive, and it lets
882        // go before it copies, so the launches queued behind it do not wait for
883        // its copy too. Once more if the tree still is not ready: a job edited
884        // it meanwhile, or a seed cannot be made here. Then it copies under what
885        // it just made, which no other launch can change.
886        let mut installs = 0;
887        let (hold, (installed, origin)) = loop {
888            let queued = std::time::Instant::now();
889            let hold = timed(&mut timings.wait, trees.read()).await;
890            if let Some(ready) = self.ready(&destination, timings).await? {
891                break (hold, ready);
892            }
893            drop(hold);
894            // An install that failed while this launch queued for it answers
895            // this launch too. Trying again at once, one queued launch after
896            // another, would hold every copy of the package back for as many
897            // stalled downloads as there are launches; the launch's own retry,
898            // after its back-off, may try the download.
899            let exclusive = timed(&mut timings.wait, trees.write()).await;
900            if let Some(reason) = self.shared.install_failed_since(queued) {
901                return Err(reason);
902            }
903            let installed = self.install_trusted(&destination, timings).await;
904            self.shared.note_install(installed.as_ref().map(|_| ()));
905            let ready = installed?;
906            installs += 1;
907            if installs == 2 {
908                break (exclusive.downgrade(), ready);
909            }
910        };
911        // Taken under the hold, so it is the record of the tree this copies. None
912        // vouches for a tree with no record: before it is copied, not after.
913        let Some(contents) = self.shared.trusted.contents_of(installed.root()) else {
914            self.shared.trusted.forget(installed.root());
915            return Err(FailureReason::ProcessStartFailed);
916        };
917        let copied_from = origin.clone();
918        // Off the async thread: the daemon runs a current-thread runtime, and a
919        // copy can take minutes on a busy disk.
920        let copied = timed(
921            &mut timings.copy,
922            self.blocking(installed.root(), {
923                let destination = destination.clone();
924                move |trusted, source| {
925                    copy_package_from(source, &origin, &destination, trusted)?;
926                    // Checked again after the copy: an edit a job made while it ran
927                    // moved the entry's print, and the runtime may hold it.
928                    if trusted.holds(source) {
929                        Ok(())
930                    } else {
931                        Err(std::io::Error::other(
932                            "the runner package changed while copied",
933                        ))
934                    }
935                }
936            }),
937        )
938        .await?;
939        if copied.is_err() {
940            // Nothing a failed or tainted copy left may reach a retry's copy,
941            // which lays out over the same runtime.
942            drop(hold);
943            self.reject_copy(attempt, installed.root(), &copied_from, &contents, timings)
944                .await?;
945            return Err(FailureReason::ProcessStartFailed);
946        }
947        if let Err(error) = self.cache.lease(attempt, installed.version()) {
948            // Undoing the copy must not undo the *job* workspace: a persistent
949            // slot's `_work` is retained across attempts, and this rollback
950            // runs before the attempt that would have owned it ever started.
951            drop(hold);
952            self.reject_copy(attempt, installed.root(), &copied_from, &contents, timings)
953                .await?;
954            return Err(package_failure(error));
955        }
956        // Leased while still holding the trees, so a prune that gets the
957        // exclusive hold finds this runtime's lease.
958        drop(hold);
959        // What runs is this copy, so this copy is what is checked, whatever
960        // happened to the shared trees meanwhile: read in full against the
961        // archive, under neither the host lock nor the trees' hold.
962        let persistent = matches!(attempt.workspace(), AttemptWorkspace::PersistentSlot { .. });
963        let checking = Arc::clone(&contents);
964        let verified = timed(
965            &mut timings.verify,
966            self.blocking(installed.root(), move |_, _| {
967                checking.verify(&destination, |name| persistent && is_work_folder(name))
968            }),
969        )
970        .await?;
971        if let Err(mismatch) = verified {
972            let _ = self.cache.release(attempt.id);
973            tracing::warn!(
974                event = "package_copy_differs",
975                attempt_id = %attempt.id,
976                reason = mismatch.as_str(),
977                "a runner's package copy does not match the package's verified archive; it is \
978                 being removed and will not be used"
979            );
980            self.reject_copy(attempt, installed.root(), &copied_from, &contents, timings)
981                .await?;
982            return Err(FailureReason::ProcessStartFailed);
983        }
984        Ok(installed.version().clone())
985    }
986
987    fn release(&self, attempt: AttemptId) -> Result<(), FailureReason> {
988        self.cache.release(attempt).map_err(package_failure)
989    }
990
991    /// Everything but the newest version, when nothing is using the trees: a
992    /// copy from an older version, or an install in progress, makes this a
993    /// no-op, and a later launch prunes what it left, which costs only disk.
994    fn prune_obsolete_guarded(
995        &self,
996        _authority: PruneAuthority<'_>,
997        attempts: &[RunnerAttempt],
998    ) -> Result<(), FailureReason> {
999        let Ok(_exclusive) = self.shared.trees.try_write() else {
1000            return Ok(());
1001        };
1002        let Some(current) = self.cache.newest_installed().map_err(package_failure)? else {
1003            return Ok(());
1004        };
1005        for installed in self.cache.installed().map_err(package_failure)? {
1006            if installed.version() != current.version() {
1007                match self.cache.prune(installed.version(), attempts) {
1008                    Ok(()) => self.shared.trusted.forget(installed.root()),
1009                    Err(PackageError::VersionInUse { .. }) => {}
1010                    Err(error) => return Err(package_failure(error)),
1011                }
1012            }
1013        }
1014        Ok(())
1015    }
1016}
1017
1018fn package_failure(error: PackageError) -> FailureReason {
1019    error.failure_reason().unwrap_or(FailureReason::Other(
1020        "runner package cache operation failed".into(),
1021    ))
1022}
1023
1024fn package_failure_is_terminal(reason: &FailureReason) -> bool {
1025    matches!(
1026        reason,
1027        FailureReason::RunnerPackageUnverified | FailureReason::RunnerVersionRejected
1028    )
1029}
1030
1031/// How many hex characters of the attempt id name its workspace.
1032///
1033/// # Why this is not the whole identifier, and why the policy is not in the path
1034///
1035/// Windows refuses a path over `MAX_PATH`, and the runner writes deep inside
1036/// this directory: `_work/<repo>/<repo>/.git/objects/pack/pack-<40 hex>.keep`
1037/// is 100 characters on its own before the repository is named twice. The
1038/// layout used to add two full identifiers -- the policy's and the attempt's,
1039/// 74 characters between them -- and that was enough to put a real checkout
1040/// over the line. Measured, not guessed: this repository's own CI failed here
1041/// three times in a row at 264 characters against a limit of 260, with
1042/// `fatal: cannot write keep file ...: Filename too long`. A repository whose
1043/// name is ten characters longer would have missed by fourteen.
1044///
1045/// The policy identifier is simply redundant -- an attempt identifier is
1046/// unique on its own, and nothing reads the directory tree to find a policy's
1047/// attempts, because [`crate::lifecycle::LifecycleLauncher`] asks the journal.
1048/// Twelve hex characters of the attempt is 48 bits, which for the handful of
1049/// directories one host holds at once is not a collision anybody will see, and
1050/// the journal keeps the full identifier either way.
1051///
1052/// Together that is 61 characters returned to the repository name.
1053const WORKSPACE_NAME_LEN: usize = 12;
1054
1055/// The directory name for one attempt's workspace.
1056fn workspace_name(id: AttemptId) -> String {
1057    let full = id.to_string();
1058    full.chars()
1059        .filter(|c| *c != '-')
1060        .take(WORKSPACE_NAME_LEN)
1061        .collect()
1062}
1063
1064/// Where one attempt's files go, and which cleanup algorithm they are owed.
1065///
1066/// The pair travels together because journalling them apart is exactly the bug
1067/// `AttemptWorkspace` exists to prevent: a `runtime_path` under a persistent
1068/// root recorded as ephemeral would be removed whole, taking the retained job
1069/// workspace with it.
1070#[derive(Debug, Clone)]
1071struct Placement {
1072    runtime: PathBuf,
1073    workspace: AttemptWorkspace,
1074}
1075
1076/// A runner-root refusal, rendered for the operator who has to fix it.
1077///
1078/// `RunnerRootError`'s `Display` already names the path, the relation and the
1079/// remediation command, and none of its variants can carry a credential — they
1080/// are paths, and `03-migration-rollout.md` requires the remediation command to
1081/// reach the operator verbatim.
1082fn root_failure(error: RunnerRootError) -> LifecycleError {
1083    // Logged here because here is the last place this is known at all. A launch
1084    // refused by the runner root fails *before* `record_allocation`, so no
1085    // attempt row is ever written: `b2` has nothing to carry the failure on and
1086    // `g2` has nothing to show it from. The lifecycle event keeps only
1087    // `reason=other`, by `failure_reason_kind`'s rule that no free text may
1088    // reach an event -- which left the whole refusal reading
1089    // `runner_start_failed reason=other`, once per poll, naming nothing.
1090    //
1091    // What travels is the *kind* and not the sentence, and that is forced rather
1092    // than chosen: `crate::logging` redacts every field it does not allow-list
1093    // and then scrubs anything path-shaped out of the ones it does, so a
1094    // rendered `RunnerRootError` -- which is mostly paths -- reaches the log as
1095    // `[redacted]`. `error_kind` is allow-listed and `RunnerRootError::kind` is
1096    // a closed vocabulary that survives the scrub, so this names which of a
1097    // dozen causes the operator has. The paths and the remediation reach them
1098    // through the command line, which is not redacted.
1099    tracing::warn!(
1100        error_kind = error.kind(),
1101        "the runner root refused this launch, so no attempt was created; the host will \
1102         retry every poll until the cause is resolved. Re-running `host set-runtime-root` \
1103         with the same path re-runs this check and prints the directory and the \
1104         remediation in full"
1105    );
1106    LifecycleError::Failed(FailureReason::Other(error.to_string()))
1107}
1108
1109/// The lowest positive slot inside `ceiling` that no uncleaned attempt holds.
1110///
1111/// `leases` is the journal's answer to "which slots are leased"
1112/// (`Store::slot_leases_for_policy`), which deliberately includes a terminal
1113/// attempt whose cleanup has not finished: that attempt still owns its
1114/// directory, so its slot is not free even though it no longer counts against
1115/// host capacity. `None` means the ceiling is reached, which is a refusal and
1116/// not a reason to allocate `s(ceiling + 1)`.
1117fn lowest_free_slot(leases: &[RunnerAttempt], ceiling: NonZeroU16) -> Option<NonZeroU16> {
1118    let held: BTreeSet<u16> = leases
1119        .iter()
1120        .filter_map(|attempt| attempt.workspace().slot_number())
1121        .collect();
1122    (1..=ceiling.get())
1123        .find(|slot| !held.contains(slot))
1124        .and_then(NonZeroU16::new)
1125}
1126
1127/// Create `<root>/sN`, or prove that what is already there is a real directory.
1128///
1129/// A symlink, junction or reparse point standing where the slot should be is
1130/// refused rather than followed: it is the one thing that could put an attempt's
1131/// files outside the root the operator configured, and
1132/// `04-security-recovery.md` requires that case to fail closed rather than to
1133/// be repaired here.
1134fn create_or_validate_slot(slot: &Path) -> Result<(), LifecycleError> {
1135    match fs::symlink_metadata(slot) {
1136        // [`is_link_like`] and not `is_symlink`, so that this is the same
1137        // question cleanup asks in [`slot_is_present`]: a reparse tag the
1138        // standard library has no name for is refused here rather than
1139        // allocated into and then quarantined forever by a cleanup that will
1140        // not scrub it.
1141        Ok(metadata) if is_link_like(&metadata) => Err(slot_refusal(
1142            slot,
1143            "is a symbolic link, junction or other reparse point, which could place runner \
1144             files outside the configured root",
1145        )),
1146        Ok(metadata) if !metadata.is_dir() => Err(slot_refusal(slot, "is not a directory")),
1147        Ok(_) => Ok(()),
1148        Err(error) if error.kind() == std::io::ErrorKind::NotFound => fs::create_dir(slot)
1149            .map_err(|source| slot_refusal(slot, format!("could not be created: {source}"))),
1150        Err(source) => Err(slot_refusal(
1151            slot,
1152            format!("could not be inspected: {source}"),
1153        )),
1154    }
1155}
1156
1157/// Accept a slot for reuse only when it is empty or holds one real `_work`.
1158///
1159/// `02-target-architecture.md`: "Before materialization, a reusable slot must
1160/// contain only a valid real `_work` directory or be empty." Everything else —
1161/// a leftover `bin/`, a link-shaped `_work`, a stray file — is refused here
1162/// rather than cleaned, because deciding whether those bytes are safe is
1163/// cleanup's and recovery's job (`c3`), and quietly reusing them would hand one
1164/// repository's retained state to the next attempt without anybody choosing to.
1165///
1166/// The inspection is one level deep and uses `symlink_metadata`, so nothing is
1167/// followed while it is being judged.
1168fn accept_reusable_slot(slot: &Path) -> Result<(), LifecycleError> {
1169    let unreadable =
1170        |source: std::io::Error| slot_refusal(slot, format!("could not be read: {source}"));
1171    let entries = fs::read_dir(slot).map_err(unreadable)?;
1172    let mut refused: Vec<String> = Vec::new();
1173    for entry in entries {
1174        let entry = entry.map_err(unreadable)?;
1175        let name = entry.file_name();
1176        let metadata = fs::symlink_metadata(entry.path()).map_err(|source| {
1177            slot_refusal(
1178                slot,
1179                format!("entry {name:?} could not be inspected: {source}"),
1180            )
1181        })?;
1182        // The same predicate cleanup retains by, so a `_work` this accepts is
1183        // one [`scrub_slot_entries`] will keep rather than refuse: a link, a
1184        // junction or any other reparse point is not a job workspace to either
1185        // of them.
1186        if is_retainable_work_folder(&name, &metadata) {
1187            continue;
1188        }
1189        refused.push(name.to_string_lossy().into_owned());
1190    }
1191    if refused.is_empty() {
1192        return Ok(());
1193    }
1194    refused.sort();
1195    Err(slot_refusal(
1196        slot,
1197        format!(
1198            "holds {} that this attempt may not reuse: [{}]. A reusable slot is empty or holds \
1199             one real `{DEFAULT_WORK_FOLDER}` directory and nothing else; remove or move the \
1200             entries listed, or let cleanup and recovery resolve them",
1201            if refused.len() == 1 {
1202                "an entry"
1203            } else {
1204                "entries"
1205            },
1206            refused.join(", ")
1207        ),
1208    ))
1209}
1210
1211fn slot_refusal(slot: &Path, detail: impl fmt::Display) -> LifecycleError {
1212    LifecycleError::Failed(FailureReason::Other(format!(
1213        "the persistent slot {} {detail}",
1214        slot.display()
1215    )))
1216}
1217
1218/// Whether a directory entry names the retained job workspace.
1219///
1220/// The comparison folds case on Windows because the filesystem does: there
1221/// `_Work` and `_work` are one directory, so a case-sensitive test would let
1222/// [`scrub_slot_entries`] delete the very directory it exists to keep, let
1223/// [`accept_reusable_slot`] refuse a slot that holds nothing but a valid job
1224/// workspace, and let a package's top-level `_Work` merge itself into the
1225/// previous attempt's `_work`. Elsewhere the two names really are two
1226/// directories and only the exact one is the job workspace.
1227fn is_work_folder(name: &OsStr) -> bool {
1228    if cfg!(windows) {
1229        name.eq_ignore_ascii_case(DEFAULT_WORK_FOLDER)
1230    } else {
1231        name == OsStr::new(DEFAULT_WORK_FOLDER)
1232    }
1233}
1234
1235/// Whether the operating system would follow this entry somewhere else.
1236///
1237/// `FileType::is_symlink` is the whole answer on Unix. On Windows it is not:
1238/// the standard library reports only the symlink and mount-point reparse tags,
1239/// and the substitution this has to refuse is *any* reparse point standing
1240/// where a real directory should be. So the attribute bit is the test there,
1241/// and a tag the standard library has no name for fails closed with the two it
1242/// does.
1243fn is_link_like(metadata: &fs::Metadata) -> bool {
1244    if metadata.file_type().is_symlink() {
1245        return true;
1246    }
1247    #[cfg(windows)]
1248    {
1249        use std::os::windows::fs::MetadataExt;
1250
1251        const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
1252        metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
1253    }
1254    #[cfg(not(windows))]
1255    false
1256}
1257
1258/// Whether an entry is the retained job workspace and is safe to retain.
1259///
1260/// The two halves are one question. A `_work` that is a file, a symlink, a
1261/// junction or any other reparse point is not a job workspace, and it is also
1262/// the exact substitution a hostile workflow makes to send cleanup somewhere
1263/// else (`04-security-recovery.md`, "A workflow replaces `_work` with a
1264/// junction or symlink to escape cleanup").
1265fn is_retainable_work_folder(name: &OsStr, metadata: &fs::Metadata) -> bool {
1266    is_work_folder(name) && metadata.is_dir() && !is_link_like(metadata)
1267}
1268
1269/// Undo one package materialization, dispatching on the journalled workspace.
1270///
1271/// The ephemeral half is what this always did: the directory is the attempt's
1272/// alone, so it goes whole. The persistent half removes the copy and nothing
1273/// else, because the slot's `_work` predates this attempt and outlives it
1274/// (`02-target-architecture.md`, "Persistent repository").
1275fn remove_materialized_package(attempt: &RunnerAttempt) -> std::io::Result<()> {
1276    match attempt.workspace() {
1277        AttemptWorkspace::Ephemeral => remove_runtime_tree(attempt.runtime_path()),
1278        AttemptWorkspace::PersistentSlot { .. } => scrub_slot_entries(attempt.runtime_path())
1279            .map_err(|quarantine| std::io::Error::other(quarantine.to_string())),
1280    }
1281}
1282
1283/// Remove a disposable runner tree without opening workflow-created special files.
1284///
1285/// On Unix, `remove_dir_all` 1.0 can open a FIFO while walking a directory and
1286/// wait forever for its peer. The .NET runner routinely leaves diagnostic FIFOs
1287/// in its private `tmp`, so use the standard library's fd-relative Unix remover,
1288/// which unlinks non-directories without opening them. Keep the external remover
1289/// on Windows for its existing read-only and long-path handling; when it is
1290/// refused, [`unlink_reparse_points`] takes every link out of its way and it
1291/// runs once more.
1292fn remove_runtime_tree(path: &Path) -> std::io::Result<()> {
1293    #[cfg(windows)]
1294    {
1295        match remove_dir_all::remove_dir_all(path) {
1296            Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
1297                unlink_reparse_points(path);
1298                remove_dir_all::remove_dir_all(path)
1299            }
1300            other => other,
1301        }
1302    }
1303    #[cfg(not(windows))]
1304    {
1305        fs::remove_dir_all(path)
1306    }
1307}
1308
1309/// Remove every symlink, junction and other reparse point under `dir` as a
1310/// link, without ever descending into one.
1311///
1312/// WinINet creates `…\INetCache\Content.IE5` as a junction whose own DACL denies
1313/// `Everyone` the right to list it, and a job that points `USERPROFILE` or
1314/// `LOCALAPPDATA` into its workspace gets one there. Both `remove_dir_all` 1.0
1315/// and `std::fs::remove_dir_all` then fail on the whole tree with
1316/// `ERROR_ACCESS_DENIED`, so the attempt could never be cleaned. Removing the
1317/// link itself is a different operation (`RemoveDirectoryW` on the reparse
1318/// point) that the deny entry does not cover, and once the links are gone the
1319/// tree is ordinary.
1320///
1321/// Only walked after a refusal, so an ordinary tree is traversed once. The
1322/// metadata comes from the directory listing itself, which on Windows
1323/// describes a reparse point without opening it.
1324///
1325/// Best effort: a failure here is not reported, because
1326/// [`remove_runtime_tree`]'s second `remove_dir_all` runs next and its result
1327/// is the authority on whether the tree went.
1328#[cfg(windows)]
1329fn unlink_reparse_points(dir: &Path) {
1330    let Ok(entries) = fs::read_dir(dir) else {
1331        return;
1332    };
1333    for entry in entries.flatten() {
1334        let path = entry.path();
1335        let Ok(metadata) = entry.metadata() else {
1336            continue;
1337        };
1338        if is_link_like(&metadata) {
1339            let _ = fs::remove_dir(&path).or_else(|_| fs::remove_file(&path));
1340        } else if metadata.is_dir() {
1341            unlink_reparse_points(&path);
1342        }
1343    }
1344}
1345
1346// ---------------------------------------------------------------------------
1347// Persistent cleanup (`04-security-recovery.md`, "Safe path handling")
1348// ---------------------------------------------------------------------------
1349
1350/// Why a persistent slot could not be proven safe to scrub.
1351///
1352/// A closed set rather than a formatted string, for two reasons that point the
1353/// same way. [`LifecycleEvent::AttemptCleanFailed`] takes a `&'static str` for
1354/// exactly the reason [`crate::reconcile::failure_reason_kind`] documents —
1355/// free text is the one shape that can carry a credential past a field
1356/// allow-list. And the entries under a slot root are *workflow-controlled*: a
1357/// job that writes a file named after a secret would publish it through any
1358/// message that echoed a directory listing, which is why nothing here ever
1359/// renders an entry name that did not come from this module's own constants.
1360#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1361enum SlotRefusal {
1362    /// The journalled runtime path is not `<root>/sN` for the journalled slot.
1363    NotTheJournalledSlot,
1364    /// A policy that still exists names a different root than the journal does.
1365    PolicyRootDisagrees,
1366    /// The slot is not strictly inside its root once components resolve.
1367    Containment,
1368    /// The slot itself is a file, a link, or could not be inspected.
1369    SlotNotADirectory,
1370    /// The slot's direct entries could not be listed.
1371    Enumeration,
1372    /// `_work` is a file, a symlink, a junction or another reparse point.
1373    WorkNotADirectory,
1374    /// An entry that had to go could not be removed.
1375    Deletion,
1376    /// Something other than the job workspace survived removal.
1377    Residue,
1378}
1379
1380impl SlotRefusal {
1381    /// The event field: a fixed vocabulary, never operator or workflow text.
1382    const fn class(self) -> &'static str {
1383        match self {
1384            Self::NotTheJournalledSlot => "slot_path_is_not_the_journalled_slot",
1385            Self::PolicyRootDisagrees => "slot_root_disagrees_with_policy",
1386            Self::Containment => "slot_escapes_its_root",
1387            Self::SlotNotADirectory => "slot_is_not_a_directory",
1388            Self::Enumeration => "slot_could_not_be_enumerated",
1389            Self::WorkNotADirectory => "retained_work_is_not_a_directory",
1390            Self::Deletion => "slot_entry_could_not_be_removed",
1391            Self::Residue => "slot_still_holds_runner_state",
1392        }
1393    }
1394
1395    /// What the operator has to do, in one sentence and with no path in it.
1396    const fn remediation(self) -> &'static str {
1397        match self {
1398            Self::NotTheJournalledSlot | Self::PolicyRootDisagrees | Self::Containment => {
1399                "the attempt keeps its slot lease and nothing was removed; correct the \
1400                 repository's persistent workspace path, or remove the slot directory by hand \
1401                 once you have confirmed what is in it"
1402            }
1403            Self::SlotNotADirectory | Self::WorkNotADirectory => {
1404                "the attempt keeps its slot lease and nothing was removed; a job replaced the \
1405                 slot or its `_work` with a link, so inspect it before deleting anything and \
1406                 treat the retained workspace as untrusted"
1407            }
1408            Self::Enumeration | Self::Deletion | Self::Residue => {
1409                "the attempt keeps its slot lease and will be cleaned again on the next pass; \
1410                 release whatever is holding the files open, or remove the slot's contents by \
1411                 hand leaving only `_work`"
1412            }
1413        }
1414    }
1415}
1416
1417/// A refusal that leaves one persistent slot quarantined.
1418///
1419/// `detail` is redacted by construction: it may hold paths this product
1420/// configured, `std::io::ErrorKind` values, counts, and names drawn from
1421/// [`SENSITIVE_SLOT_ENTRIES`] — and nothing that came out of a directory
1422/// listing.
1423#[derive(Debug, Clone, PartialEq, Eq)]
1424struct SlotQuarantine {
1425    refusal: SlotRefusal,
1426    detail: String,
1427}
1428
1429impl SlotQuarantine {
1430    fn new(refusal: SlotRefusal, detail: impl Into<String>) -> Self {
1431        Self {
1432            refusal,
1433            detail: detail.into(),
1434        }
1435    }
1436}
1437
1438impl fmt::Display for SlotQuarantine {
1439    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1440        write!(f, "{}; {}", self.detail, self.refusal.remediation())
1441    }
1442}
1443
1444/// Steps 1 to 3: the journalled root, the journalled slot, and containment.
1445///
1446/// Everything comes from the two immutable allocation facts — the exact runtime
1447/// path and the slot number. Not from the filesystem: scanning a root to decide
1448/// which directories are "mine" is what invariant 6 forbids, and it is also
1449/// impossible for an attempt whose policy has since been deleted. `configured`
1450/// is therefore a *cross-check* and not a source. A policy that survives has to
1451/// agree; a policy that does not survive removes a check rather than the
1452/// ability to clean the directory the journal already names.
1453///
1454/// The name test comes before the parent is used, so `<root>/s1` for a journal
1455/// row that says `s2` — and anything at all that is not one `sN` component —
1456/// is refused before a root is derived from it.
1457fn verify_journalled_slot(
1458    runtime: &Path,
1459    slot: NonZeroU16,
1460    configured: Option<&LocalAbsolutePath>,
1461) -> Result<(), SlotQuarantine> {
1462    let mislaid = || {
1463        SlotQuarantine::new(
1464            SlotRefusal::NotTheJournalledSlot,
1465            format!(
1466                "the journalled runtime {} is not the slot s{slot} this attempt was allocated as",
1467                runtime.display()
1468            ),
1469        )
1470    };
1471    let local = |path: &Path| {
1472        path.to_str()
1473            .and_then(|raw| LocalAbsolutePath::new(raw).ok())
1474            .ok_or_else(mislaid)
1475    };
1476
1477    let runtime_path = local(runtime)?;
1478    let root = local(runtime.parent().ok_or_else(mislaid)?)?;
1479    // Containment's lexical half, by construction: `derive_child` accepts one
1480    // component, so the equality below can only hold when the journalled path
1481    // really is this root's `sN` and nothing else.
1482    // The directory name comes from the domain that allocation named it with,
1483    // never from a second `s{n}` spelled out here: a convention with two
1484    // spellings would let cleanup refuse every slot the allocator created.
1485    let name = AttemptWorkspace::persistent_slot(slot)
1486        .slot_directory_name()
1487        .expect("a persistent workspace names its slot directory");
1488    let derived = runner_root::derive_child(&root, &name).map_err(|_| mislaid())?;
1489    if derived != runtime_path {
1490        return Err(mislaid());
1491    }
1492    if let Some(configured) = configured
1493        && configured != &root
1494    {
1495        return Err(SlotQuarantine::new(
1496            SlotRefusal::PolicyRootDisagrees,
1497            format!(
1498                "the journalled slot {} is not under the repository's configured persistent root \
1499                 {}",
1500                runtime.display(),
1501                configured.as_str()
1502            ),
1503        ));
1504    }
1505    // And containment's canonical half, which is what a junction planted inside
1506    // the root between allocation and cleanup has to get past.
1507    runner_root::verify_containment(&root, &derived).map_err(|source| {
1508        SlotQuarantine::new(
1509            SlotRefusal::Containment,
1510            format!("the journalled slot is not inside the root it was allocated from: {source}"),
1511        )
1512    })
1513}
1514
1515/// Whether the slot is there to be scrubbed at all, before its entries are.
1516///
1517/// `Ok(false)` — the directory is gone — is not a refusal. There is nothing to
1518/// remove and nothing to prove absent, which is the same tolerance the
1519/// disposable arm has always had for a runtime that vanished under it.
1520///
1521/// A slot that is a file, a link, a junction or any other reparse point *is* a
1522/// refusal, and it is checked here rather than inside the enumeration so that
1523/// the reason an operator reads names the shape rather than reporting that a
1524/// directory could not be listed.
1525fn slot_is_present(slot: &Path) -> Result<bool, SlotQuarantine> {
1526    match fs::symlink_metadata(slot) {
1527        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
1528        Err(source) => Err(SlotQuarantine::new(
1529            SlotRefusal::SlotNotADirectory,
1530            format!(
1531                "the slot {} could not be inspected: {:?}",
1532                slot.display(),
1533                source.kind()
1534            ),
1535        )),
1536        Ok(metadata) if !metadata.is_dir() || is_link_like(&metadata) => Err(SlotQuarantine::new(
1537            SlotRefusal::SlotNotADirectory,
1538            format!(
1539                "the slot {} is a link or a file rather than a real directory",
1540                slot.display()
1541            ),
1542        )),
1543        Ok(_) => Ok(true),
1544    }
1545}
1546
1547/// Steps 4 to 6: list the slot's direct entries, keep one real `_work`, remove
1548/// every other one.
1549///
1550/// Every call is a literal filesystem API against a path built from the slot
1551/// root and one entry name — no glob, no shell string, no repository-controlled
1552/// fragment. Nothing is followed: `symlink_metadata` says what each entry *is*,
1553/// and a link-shaped entry is unlinked rather than descended into, so a junction
1554/// planted where `bin` used to be cannot take the deletion outside the slot.
1555///
1556/// A `_work` that is not a real directory is the one entry this refuses to act
1557/// on at all. Removing it could destroy an operator's data if the link were
1558/// theirs; keeping it would hand the next attempt a workspace pointing
1559/// anywhere. `04-security-recovery.md` requires that case to quarantine the
1560/// slot, so the whole scrub stops there with nothing removed after it.
1561fn scrub_slot_entries(slot: &Path) -> Result<(), SlotQuarantine> {
1562    let unreadable = |source: std::io::Error| {
1563        SlotQuarantine::new(
1564            SlotRefusal::Enumeration,
1565            format!(
1566                "the entries of {} could not be listed: {:?}",
1567                slot.display(),
1568                source.kind()
1569            ),
1570        )
1571    };
1572    for entry in fs::read_dir(slot).map_err(unreadable)? {
1573        let name = entry.map_err(unreadable)?.file_name();
1574        let path = slot.join(&name);
1575        // Nothing is assumed from an entry that vanished: `verify_slot_scrubbed`
1576        // asks the filesystem again afterwards and refuses if it is still there.
1577        let Some(metadata) = listed_entry_metadata(&path).map_err(unreadable)? else {
1578            continue;
1579        };
1580        if is_work_folder(&name) {
1581            if is_retainable_work_folder(&name, &metadata) {
1582                continue;
1583            }
1584            return Err(SlotQuarantine::new(
1585                SlotRefusal::WorkNotADirectory,
1586                format!(
1587                    "the retained `{DEFAULT_WORK_FOLDER}` in {} is a link or a file rather than a \
1588                     real directory",
1589                    slot.display()
1590                ),
1591            ));
1592        }
1593        remove_slot_entry(&path, &metadata).map_err(|source| {
1594            SlotQuarantine::new(
1595                SlotRefusal::Deletion,
1596                format!(
1597                    "an entry of {} could not be removed: {:?}",
1598                    slot.display(),
1599                    source.kind()
1600                ),
1601            )
1602        })?;
1603    }
1604    Ok(())
1605}
1606
1607/// What a listed entry *is*, or `None` when it is no longer there.
1608///
1609/// An entry named by a listing and gone by the time it is stat-ed is absent,
1610/// which is a fact both passes over a slot want rather than an enumeration that
1611/// failed. Nothing is followed: `symlink_metadata` reports a link as a link.
1612fn listed_entry_metadata(path: &Path) -> std::io::Result<Option<fs::Metadata>> {
1613    match fs::symlink_metadata(path) {
1614        Ok(metadata) => Ok(Some(metadata)),
1615        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
1616        Err(source) => Err(source),
1617    }
1618}
1619
1620/// Remove one slot entry without following it.
1621///
1622/// An entry that is already gone is removed: the post-condition this serves is
1623/// "not there", and racing with whatever removed it first is not a refusal.
1624fn remove_slot_entry(path: &Path, metadata: &fs::Metadata) -> std::io::Result<()> {
1625    let removed = if is_link_like(metadata) {
1626        // A file symlink unlinks with `remove_file`; a directory symlink or a
1627        // Windows junction needs `remove_dir`. Neither follows the link.
1628        fs::remove_file(path).or_else(|_| fs::remove_dir(path))
1629    } else if metadata.is_dir() {
1630        remove_runtime_tree(path)
1631    } else {
1632        fs::remove_file(path)
1633    };
1634    match removed {
1635        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
1636        other => other,
1637    }
1638}
1639
1640/// Step 7: prove that nothing but the retained job workspace is left.
1641///
1642/// Two passes, deliberately independent, because the interesting failure is an
1643/// enumeration that under-reports. The first asks the directory what remains
1644/// and counts everything that is not a real `_work`. The second ignores the
1645/// listing entirely and stats the names an attempt is known to write — the
1646/// runner's binaries, the registration identity it stores beside them, the
1647/// process-identity sidecars and this agent's lifecycle marks — so a scrub that
1648/// silently skipped one is caught by a question that never consulted the
1649/// listing that skipped it.
1650///
1651/// Only the second pass names anything, and the encoded JIT handoff is reported
1652/// by its published prefix rather than by the UUID that follows it. A slot root
1653/// is workflow-writable, so a job that named a file after a secret would publish
1654/// it through any message that echoed the listing; the first pass therefore
1655/// reports a count.
1656fn verify_slot_scrubbed(slot: &Path) -> Result<(), SlotQuarantine> {
1657    let unreadable = |source: std::io::Error| {
1658        SlotQuarantine::new(
1659            SlotRefusal::Enumeration,
1660            format!(
1661                "the entries of {} could not be listed to verify the scrub: {:?}",
1662                slot.display(),
1663                source.kind()
1664            ),
1665        )
1666    };
1667    let mut residue = 0_usize;
1668    let mut named: Vec<String> = Vec::new();
1669    for entry in fs::read_dir(slot).map_err(unreadable)? {
1670        let name = entry.map_err(unreadable)?.file_name();
1671        // Absent is what this pass is here to establish. The named half below
1672        // stats every sensitive entry again, so an entry that only *looks*
1673        // absent to this listing is still caught.
1674        let Some(metadata) = listed_entry_metadata(&slot.join(&name)).map_err(unreadable)? else {
1675            continue;
1676        };
1677        if is_retainable_work_folder(&name, &metadata) {
1678            continue;
1679        }
1680        residue = residue.saturating_add(1);
1681        if name
1682            .to_string_lossy()
1683            .starts_with(RestrictiveHandoff::NAME_PREFIX)
1684        {
1685            named.push("an encoded JIT handoff".to_owned());
1686        }
1687    }
1688    named.extend(
1689        SENSITIVE_SLOT_ENTRIES
1690            .iter()
1691            .filter(|entry| fs::symlink_metadata(slot.join(entry)).is_ok())
1692            .map(|entry| format!("`{entry}`")),
1693    );
1694    if residue == 0 && named.is_empty() {
1695        return Ok(());
1696    }
1697    named.sort_unstable();
1698    named.dedup();
1699    Err(SlotQuarantine::new(
1700        SlotRefusal::Residue,
1701        residue_detail(slot, residue, &named),
1702    ))
1703}
1704
1705/// Word a [`SlotRefusal::Residue`] refusal from the two facts that produced it.
1706///
1707/// Separate from [`verify_slot_scrubbed`] because the disagreement it has to
1708/// report — a listing that counted nothing and a filesystem that answered
1709/// otherwise — is a race no test can stage, and a message that contradicts
1710/// itself is exactly what an operator reads at three in the morning.
1711///
1712/// `named` is the sanitized half: entries this crate published the names of.
1713/// A name a workflow chose is only ever counted, never echoed.
1714fn residue_detail(slot: &Path, residue: usize, named: &[String]) -> String {
1715    if residue == 0 {
1716        // The whole reason the second pass ignores the listing: the listing
1717        // reported a clean slot and the filesystem disagrees. Saying "0
1718        // entries survived" here would report the under-count as the fact.
1719        format!(
1720            "the listing of {} reported nothing but `{DEFAULT_WORK_FOLDER}`, yet {} survived \
1721                 cleanup",
1722            slot.display(),
1723            named.join(", ")
1724        )
1725    } else {
1726        format!(
1727            "{residue} entr{} other than `{DEFAULT_WORK_FOLDER}` survived cleanup of {}{}",
1728            if residue == 1 { "y" } else { "ies" },
1729            slot.display(),
1730            if named.is_empty() {
1731                String::new()
1732            } else {
1733                format!(", including {}", named.join(", "))
1734            }
1735        )
1736    }
1737}
1738
1739fn replacement_operation(outcome: &AttemptOutcome) -> Option<&'static str> {
1740    match outcome {
1741        AttemptOutcome::Failed {
1742            reason: FailureReason::JitExpired,
1743        } => Some("jit_expired_replacement"),
1744        AttemptOutcome::Failed {
1745            reason: FailureReason::ProcessExitedUnexpectedly,
1746        } => Some("exit_before_acceptance_replacement"),
1747        _ => None,
1748    }
1749}
1750
1751/// Lay the verified runner package out *around* whatever the slot retains.
1752///
1753/// `02-target-architecture.md`: "The verified runner package is copied into the
1754/// slot for the attempt", beside a `_work` that survives every attempt. Two
1755/// properties make that safe, and both are structural rather than documented:
1756///
1757/// * the walk is of the **source** tree, so a retained `_work` in the
1758///   destination is never opened, never descended into, and cannot be followed
1759///   wherever it might point;
1760/// * a top-level source entry named `_work` is refused rather than copied, so a
1761///   package that ever grew one could not merge itself into, or replace, the
1762///   job workspace of the attempt before it. The refusal is top-level only,
1763///   because the retained directory is a direct child of the slot; a `_work`
1764///   nested inside the package's own tree is an ordinary name.
1765///
1766/// On Unix the copy is a copy-on-write clone where the filesystem offers one,
1767/// taken from a [`package_seed`] on the destination's own volume. A plain
1768/// byte copy of the ~9,000-file package across volumes took four minutes on a
1769/// host whose runner root shared a busy disk with a build, and a queued job
1770/// waits for every second of it.
1771/// The tree to copy `source` from, making a seed where one helps; a change to
1772/// a tree, so only under the exclusive hold of [`SharedPackages::trees`].
1773///
1774/// A seed that cannot be made is a slower launch, not a failed one. The seed
1775/// sits under the runner root, beside job runtimes, so the refusal is re-checked
1776/// on it; a seed that fails it is bypassed.
1777fn copy_origin(source: &Path, destination: &Path, seeds: &TrustedTrees) -> PathBuf {
1778    #[cfg(unix)]
1779    {
1780        package_seed(source, destination, seeds)
1781            .ok()
1782            .filter(|seed| refuse_work_folder(seed).is_ok())
1783            .unwrap_or_else(|| source.to_path_buf())
1784    }
1785    #[cfg(not(unix))]
1786    {
1787        let _ = (destination, seeds);
1788        source.to_path_buf()
1789    }
1790}
1791
1792/// The tree to copy `source` from if it needs no change: `None` is a seed to
1793/// rebuild first.
1794fn copy_origin_ready(source: &Path, destination: &Path, seeds: &TrustedTrees) -> Option<PathBuf> {
1795    #[cfg(unix)]
1796    {
1797        match seed_location(source, destination) {
1798            Ok(None) => Some(source.to_path_buf()),
1799            Ok(Some(seeds_root)) => held_seed(source, &seeds_root, seeds)
1800                .ok()
1801                .flatten()
1802                .filter(|seed| refuse_work_folder(seed).is_ok()),
1803            Err(_) => None,
1804        }
1805    }
1806    #[cfg(not(unix))]
1807    {
1808        let _ = (destination, seeds);
1809        Some(source.to_path_buf())
1810    }
1811}
1812
1813/// Copy the package at `source` into `destination`, from `origin`: itself or
1814/// its seed. Under the shared hold of [`SharedPackages::trees`].
1815fn copy_package_from(
1816    source: &Path,
1817    origin: &Path,
1818    destination: &Path,
1819    seeds: &TrustedTrees,
1820) -> std::io::Result<()> {
1821    refuse_work_folder(source)?;
1822
1823    #[cfg(unix)]
1824    {
1825        clone_tree(origin, destination)?;
1826        // A seed a job edited during the clone no longer holds; the caller
1827        // re-checks the cache entry itself.
1828        if origin != source && !seeds.holds(origin) {
1829            return Err(std::io::Error::other(
1830                "the package seed changed while cloned",
1831            ));
1832        }
1833        Ok(())
1834    }
1835    #[cfg(not(unix))]
1836    {
1837        let _ = (origin, seeds);
1838        copy_package_entries(source, destination, true)
1839    }
1840}
1841
1842fn refuse_work_folder(source: &Path) -> std::io::Result<()> {
1843    if source.join(DEFAULT_WORK_FOLDER).exists() {
1844        return Err(std::io::Error::new(
1845            std::io::ErrorKind::InvalidData,
1846            "a cached runner package contains a _work folder, which means it was used \
1847             to run a job before it was archived; the cache must only contain clean \
1848             extracts to prevent data leakage",
1849        ));
1850    }
1851    Ok(())
1852}
1853
1854/// Where runtime copies of the package are cloned from, beside the runtimes.
1855#[cfg(unix)]
1856const PACKAGE_SEED_DIR: &str = ".runner-package";
1857
1858/// A copy of the cache entry on the destination's volume, so that every
1859/// runtime after the first is a same-volume clone rather than a cross-volume
1860/// byte copy. Returns `source` itself when it already shares the volume.
1861///
1862/// The seed lives at `<runner root>/.runner-package/<version>` and is replaced
1863/// whenever its manifest stops matching the cache entry's, which covers both a
1864/// new version and a reinstalled one. Superseded versions and interrupted
1865/// stagings are removed on that same pass. A seed is only made under the
1866/// exclusive hold of [`SharedPackages::trees`], so no launch is cloning from it
1867/// while it is replaced.
1868///
1869/// The seed sits one `..` from every job's runtime and is owned by the account
1870/// jobs run as, so it is only reused while it is provably the tree this
1871/// process built: see [`TrustedTrees`].
1872///
1873/// Only on macOS, where the runner root is all but certainly APFS and a clone
1874/// is free. On a Linux filesystem without reflinks a seed would only move every
1875/// copy's reads onto the runner root's own, possibly busy, disk.
1876#[cfg(unix)]
1877fn package_seed(
1878    source: &Path,
1879    destination: &Path,
1880    seeds: &TrustedTrees,
1881) -> std::io::Result<PathBuf> {
1882    match seed_location(source, destination)? {
1883        Some(root) => refresh_package_seed(source, &root, seeds),
1884        None => Ok(source.to_path_buf()),
1885    }
1886}
1887
1888/// Where `destination`'s seeds live, if copies to it use one.
1889#[cfg(unix)]
1890fn seed_location(source: &Path, destination: &Path) -> std::io::Result<Option<PathBuf>> {
1891    use std::os::unix::fs::MetadataExt as _;
1892
1893    if !cfg!(target_os = "macos") {
1894        return Ok(None);
1895    }
1896    let root = destination
1897        .parent()
1898        .ok_or_else(|| std::io::Error::other("a runtime directory has no parent"))?;
1899    if fs::metadata(source)?.dev() == fs::metadata(root)?.dev() {
1900        return Ok(None);
1901    }
1902    Ok(Some(root.join(PACKAGE_SEED_DIR)))
1903}
1904
1905/// The package trees this process built or installed: what each must hold, and
1906/// what each looked like right after. They are the cache entry runtimes are copied
1907/// from and the seeds they are cloned from.
1908///
1909/// Both are owned by the account jobs run as, so a job can edit any file in them,
1910/// and nothing on disk can say what they should hold: the published digest covers
1911/// the archive, not the extracted tree, and a record kept on disk could be
1912/// rewritten by the same account. So a tree without a record here, as every tree
1913/// has after a restart, is rebuilt before it is used.
1914///
1915/// Two checks, for two costs. [`PackageContents`] reads every file against the
1916/// verified archive, on every runtime a launch is about to run: that is the one
1917/// that decides. On Unix the tree's [`tree_fingerprint`] also spots a changed
1918/// tree cheaply before any of it is copied, since every edit, replacement or
1919/// rename moves the change time (`ctime`) of the entry or of its directory, and
1920/// only the superuser can set a change time.
1921#[derive(Debug, Default)]
1922pub(crate) struct TrustedTrees {
1923    trees: Mutex<HashMap<PathBuf, TrustedTree>>,
1924}
1925
1926#[derive(Debug, Clone)]
1927struct TrustedTree {
1928    #[cfg(unix)]
1929    print: [u8; 32],
1930    contents: Option<Arc<PackageContents>>,
1931}
1932
1933impl TrustedTrees {
1934    fn trees(&self) -> std::sync::MutexGuard<'_, HashMap<PathBuf, TrustedTree>> {
1935        self.trees
1936            .lock()
1937            .unwrap_or_else(std::sync::PoisonError::into_inner)
1938    }
1939
1940    /// Whether `root` is a tree recorded here and, where that can be told, still
1941    /// looks as it did.
1942    fn holds(&self, root: &Path) -> bool {
1943        let Some(tree) = self.trees().get(root).cloned() else {
1944            return false;
1945        };
1946        #[cfg(unix)]
1947        {
1948            tree_fingerprint(root).is_ok_and(|current| current == tree.print)
1949        }
1950        #[cfg(not(unix))]
1951        {
1952            let _ = tree;
1953            true
1954        }
1955    }
1956
1957    /// What `root` must hold.
1958    fn contents_of(&self, root: &Path) -> Option<Arc<PackageContents>> {
1959        self.trees()
1960            .get(root)
1961            .and_then(|tree| tree.contents.clone())
1962    }
1963
1964    /// Trust `root` as it is now; called right after this process made it.
1965    fn record(&self, root: &Path, contents: Option<Arc<PackageContents>>) -> std::io::Result<()> {
1966        let tree = TrustedTree {
1967            #[cfg(unix)]
1968            print: tree_fingerprint(root)?,
1969            contents,
1970        };
1971        self.trees().insert(root.to_path_buf(), tree);
1972        Ok(())
1973    }
1974
1975    /// Stops trusting `root` if `contents` is still what is recorded for it, and
1976    /// answers whether it did.
1977    fn forget_if_holding(&self, root: &Path, contents: &Arc<PackageContents>) -> bool {
1978        let mut trees = self.trees();
1979        let holding = trees
1980            .get(root)
1981            .and_then(|tree| tree.contents.as_ref())
1982            .is_some_and(|current| Arc::ptr_eq(current, contents));
1983        if holding {
1984            trees.remove(root);
1985        }
1986        holding
1987    }
1988
1989    /// Stop trusting `root`: its next use rebuilds it.
1990    fn forget(&self, root: &Path) {
1991        self.trees().remove(root);
1992    }
1993}
1994
1995/// A digest of every entry under `root`: its relative path, inode, size, mode,
1996/// owner and change time.
1997#[cfg(unix)]
1998fn tree_fingerprint(root: &Path) -> std::io::Result<[u8; 32]> {
1999    use sha2::{Digest as _, Sha256};
2000    use std::os::unix::fs::MetadataExt as _;
2001
2002    fn walk(root: &Path, path: &Path, hasher: &mut Sha256) -> std::io::Result<()> {
2003        let metadata = fs::symlink_metadata(path)?;
2004        let relative = path.strip_prefix(root).unwrap_or(path);
2005        hasher.update(relative.as_os_str().as_encoded_bytes());
2006        hasher.update([0]);
2007        for field in [
2008            metadata.ino(),
2009            metadata.size(),
2010            u64::from(metadata.mode()),
2011            u64::from(metadata.uid()),
2012            u64::from(metadata.gid()),
2013        ] {
2014            hasher.update(field.to_le_bytes());
2015        }
2016        hasher.update(metadata.ctime().to_le_bytes());
2017        hasher.update(metadata.ctime_nsec().to_le_bytes());
2018        if metadata.is_dir() {
2019            let mut children = fs::read_dir(path)?
2020                .map(|entry| entry.map(|entry| entry.path()))
2021                .collect::<std::io::Result<Vec<_>>>()?;
2022            children.sort();
2023            for child in children {
2024                walk(root, &child, hasher)?;
2025            }
2026        }
2027        Ok(())
2028    }
2029
2030    let mut hasher = Sha256::new();
2031    walk(root, root, &mut hasher)?;
2032    Ok(hasher.finalize().into())
2033}
2034
2035#[cfg(unix)]
2036fn seed_path(source: &Path, seeds: &Path) -> std::io::Result<PathBuf> {
2037    let version = source
2038        .file_name()
2039        .ok_or_else(|| std::io::Error::other("a cache entry has no name"))?;
2040    Ok(seeds.join(version))
2041}
2042
2043/// The seed of `source` under `seeds`, if its manifest matches the cache
2044/// entry's and its fingerprint the one this process recorded.
2045#[cfg(unix)]
2046fn held_seed(
2047    source: &Path,
2048    seeds: &Path,
2049    trusted: &TrustedTrees,
2050) -> std::io::Result<Option<PathBuf>> {
2051    use crate::package::MANIFEST_FILE;
2052
2053    let seed = seed_path(source, seeds)?;
2054    let manifest = fs::read(source.join(MANIFEST_FILE))?;
2055    let held = fs::read(seed.join(MANIFEST_FILE)).is_ok_and(|held| held == manifest)
2056        && trusted.holds(&seed);
2057    Ok(held.then_some(seed))
2058}
2059
2060/// The seed of `source` under `seeds`, rebuilt unless [`held_seed`] finds it.
2061#[cfg(unix)]
2062fn refresh_package_seed(
2063    source: &Path,
2064    seeds: &Path,
2065    trusted: &TrustedTrees,
2066) -> std::io::Result<PathBuf> {
2067    if let Some(seed) = held_seed(source, seeds, trusted)? {
2068        return Ok(seed);
2069    }
2070    let seed = seed_path(source, seeds)?;
2071
2072    // Superseded versions, a stale seed and interrupted stagings all go.
2073    match remove_runtime_tree(seeds) {
2074        Err(error) if error.kind() != std::io::ErrorKind::NotFound => return Err(error),
2075        _ => {}
2076    }
2077    fs::create_dir_all(seeds)?;
2078    let staging = seeds.join(format!(".staging-{}", uuid::Uuid::new_v4()));
2079    fs::create_dir(&staging)?;
2080    // A half-made staging would hold the disk space the fallback copy needs.
2081    if let Err(error) = clone_tree(source, &staging).and_then(|()| fs::rename(&staging, &seed)) {
2082        let _ = remove_runtime_tree(&staging);
2083        return Err(error);
2084    }
2085    // After the rename, which itself moves the seed directory's change time.
2086    trusted.record(&seed, None)?;
2087    Ok(seed)
2088}
2089
2090/// `cp -a`, asking for a copy-on-write clone where the platform's `cp` can.
2091///
2092/// macOS `cp -c` and GNU `--reflink=auto` both fall back to a byte copy across
2093/// volumes; a `cp` that does not know the flag at all gets a plain `cp -a`.
2094#[cfg(unix)]
2095fn clone_tree(source: &Path, destination: &Path) -> std::io::Result<()> {
2096    #[cfg(target_os = "macos")]
2097    const CLONE: &[&str] = &["-c"];
2098    #[cfg(target_os = "linux")]
2099    const CLONE: &[&str] = &["--reflink=auto"];
2100    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
2101    const CLONE: &[&str] = &[];
2102
2103    let from = format!("{}/.", source.display());
2104    let cp = |extra: &[&str]| {
2105        std::process::Command::new("cp")
2106            .arg("-a")
2107            .args(extra)
2108            .arg(&from)
2109            .arg(destination)
2110            .status()
2111    };
2112    if cp(CLONE)?.success() || cp(&[])?.success() {
2113        Ok(())
2114    } else {
2115        Err(std::io::Error::other("cp failed"))
2116    }
2117}
2118
2119#[cfg(not(unix))]
2120fn copy_package_entries(source: &Path, destination: &Path, top_level: bool) -> std::io::Result<()> {
2121    fs::create_dir_all(destination)?;
2122    for entry in fs::read_dir(source)? {
2123        let entry = entry?;
2124        if top_level && is_work_folder(&entry.file_name()) {
2125            return Err(std::io::Error::new(
2126                std::io::ErrorKind::InvalidData,
2127                format!(
2128                    "the runner package holds a top-level `{DEFAULT_WORK_FOLDER}`; copying \
2129                     it would overwrite the job workspace a persistent slot retains"
2130                ),
2131            ));
2132        }
2133        let target = destination.join(entry.file_name());
2134        if entry.file_type()?.is_dir() {
2135            copy_package_entries(&entry.path(), &target, false)?;
2136        } else {
2137            fs::copy(entry.path(), target)?;
2138        }
2139    }
2140    Ok(())
2141}
2142
2143/// Process operations are attempt-addressed so a recovered process and a child
2144/// started in this invocation are supervised through one port.
2145#[derive(Debug, Clone, PartialEq, Eq)]
2146pub struct ProcessStartFailure {
2147    pub reason: FailureReason,
2148    /// False once a child existed: the one-shot JIT value may have been
2149    /// consumed, so retrying it could start a duplicate.
2150    pub retryable: bool,
2151    /// Set only when cleanup could not prove the spawned process dead.  The
2152    /// caller must journal `starting` and retain capacity/supervision.
2153    pub live_pid: Option<u32>,
2154}
2155
2156impl ProcessStartFailure {
2157    pub(crate) fn before_spawn(reason: FailureReason) -> Self {
2158        Self {
2159            reason,
2160            retryable: true,
2161            live_pid: None,
2162        }
2163    }
2164
2165    fn after_spawn_stopped() -> Self {
2166        Self {
2167            reason: FailureReason::ProcessStartFailed,
2168            retryable: false,
2169            live_pid: None,
2170        }
2171    }
2172
2173    fn after_spawn_live(pid: u32) -> Self {
2174        Self::after_spawn_live_with_reason(pid, FailureReason::ProcessStartFailed)
2175    }
2176
2177    fn after_spawn_live_with_reason(pid: u32, reason: FailureReason) -> Self {
2178        Self {
2179            reason,
2180            retryable: false,
2181            live_pid: Some(pid),
2182        }
2183    }
2184}
2185
2186/// A credential-free capability report. An isolated policy never treats a
2187/// native-ready result as permission to launch.
2188#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2189pub enum ProviderCapability {
2190    Ready,
2191    Unsupported,
2192    NotInstalled,
2193    PermissionDenied,
2194    ImageUnavailableOrIncompatible,
2195    DiskQuotaUnavailable,
2196    Degraded,
2197}
2198
2199impl ProviderCapability {
2200    #[must_use]
2201    pub const fn display_name(self) -> &'static str {
2202        match self {
2203            Self::Ready => "ready",
2204            Self::Unsupported => "unsupported",
2205            Self::NotInstalled => "not installed",
2206            Self::PermissionDenied => "permission denied",
2207            Self::ImageUnavailableOrIncompatible => "image unavailable or incompatible",
2208            Self::DiskQuotaUnavailable => "disk quota unavailable",
2209            Self::Degraded => "degraded",
2210        }
2211    }
2212
2213    pub(crate) fn refusal(self) -> FailureReason {
2214        FailureReason::IsolationProvider(match self {
2215            Self::Ready | Self::Degraded => IsolationProviderFailure::Degraded,
2216            Self::Unsupported => IsolationProviderFailure::Unsupported,
2217            Self::NotInstalled => IsolationProviderFailure::NotInstalled,
2218            Self::PermissionDenied => IsolationProviderFailure::PermissionDenied,
2219            Self::ImageUnavailableOrIncompatible => {
2220                IsolationProviderFailure::ImageUnavailableOrIncompatible
2221            }
2222            Self::DiskQuotaUnavailable => IsolationProviderFailure::DiskQuotaUnavailable,
2223        })
2224    }
2225}
2226
2227/// Only the closed isolation category may cross from an adapter into the
2228/// attempt journal or operator error. Arbitrary adapter text could echo JIT.
2229fn safe_isolation_reason(
2230    reason: FailureReason,
2231    fallback: IsolationProviderFailure,
2232) -> FailureReason {
2233    match reason {
2234        FailureReason::IsolationProvider(_) => reason,
2235        _ => FailureReason::IsolationProvider(fallback),
2236    }
2237}
2238
2239#[derive(Debug, Clone, PartialEq, Eq)]
2240pub struct PreparedEnvironment {
2241    attempt: AttemptId,
2242    identity: Option<EnvironmentIdentity>,
2243    /// The target whose dependency-cache namespace a native runner uses:
2244    /// `prepare` sees the policy and `start` does not.
2245    cache_target: Option<runner_manager_domain::model::ScaleTarget>,
2246}
2247
2248impl PreparedEnvironment {
2249    /// An adapter must return the identity independently recorded on its resource.
2250    #[must_use]
2251    pub fn isolated(attempt: AttemptId, identity: EnvironmentIdentity) -> Self {
2252        Self {
2253            attempt,
2254            identity: Some(identity),
2255            cache_target: None,
2256        }
2257    }
2258
2259    #[must_use]
2260    pub fn identity(&self) -> Option<&EnvironmentIdentity> {
2261        self.identity.as_ref()
2262    }
2263}
2264
2265#[derive(Debug, Clone, PartialEq, Eq)]
2266pub struct ResolvedEnvironment {
2267    pub provider_kind: Backend,
2268    pub image: ImageReference,
2269}
2270
2271/// Closed, non-secret provider diagnostics; adapters cannot attach arbitrary output.
2272#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2273pub enum ProviderDiagnostic {
2274    CapabilityUnavailable,
2275    ImageRejected,
2276    PrepareFailed,
2277    StartFailed,
2278    OwnershipMismatch,
2279    CleanupDeferred,
2280}
2281
2282#[derive(Debug, Clone, PartialEq, Eq)]
2283pub enum EnvironmentIdentity {
2284    NativeProcess(u32),
2285    Isolated {
2286        host: HostId,
2287        attempt: AttemptId,
2288        provider_kind: Backend,
2289        environment_id: String,
2290        resolved_image: ImageReference,
2291        generation: String,
2292    },
2293}
2294
2295#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2296pub enum EnvironmentState {
2297    Starting,
2298    Running,
2299    Exited,
2300    Missing,
2301}
2302
2303#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2304pub enum ProviderStop {
2305    Stopped,
2306    StillRunning,
2307}
2308
2309#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2310pub enum ProviderDestroy {
2311    Destroyed,
2312    Deferred(&'static str),
2313}
2314
2315fn identity_matches_intent(
2316    host: HostId,
2317    attempt: &RunnerAttempt,
2318    identity: &EnvironmentIdentity,
2319) -> bool {
2320    identity_same_allocation(host, attempt, identity)
2321        && match (attempt.execution(), identity) {
2322            (
2323                AttemptExecution::Isolated { environment_id, .. },
2324                EnvironmentIdentity::Isolated {
2325                    environment_id: id, ..
2326                },
2327            ) => environment_id
2328                .as_ref()
2329                .is_none_or(|journal_id| journal_id == id),
2330            _ => false,
2331        }
2332}
2333
2334fn identity_same_allocation(
2335    host: HostId,
2336    attempt: &RunnerAttempt,
2337    identity: &EnvironmentIdentity,
2338) -> bool {
2339    match (attempt.execution(), identity) {
2340        (
2341            AttemptExecution::Isolated {
2342                provider_kind,
2343                environment_id: _,
2344                resolved_image,
2345                generation,
2346            },
2347            EnvironmentIdentity::Isolated {
2348                host: owner,
2349                attempt: owner_attempt,
2350                provider_kind: kind,
2351                environment_id: _,
2352                resolved_image: image,
2353                generation: resource_generation,
2354            },
2355        ) => {
2356            *owner == host
2357                && *owner_attempt == attempt.id
2358                && provider_kind == kind
2359                && resolved_image == image
2360                && generation == resource_generation
2361        }
2362        _ => false,
2363    }
2364}
2365
2366/// The JIT value can be passed into a provider start exactly once. It must never
2367/// become a provider identity, label, argument, persisted configuration or log field.
2368pub struct OneTimeJitHandoff<'a> {
2369    config: &'a EncodedJitConfig,
2370}
2371
2372impl fmt::Debug for OneTimeJitHandoff<'_> {
2373    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2374        f.write_str("OneTimeJitHandoff([redacted])")
2375    }
2376}
2377
2378impl<'a> OneTimeJitHandoff<'a> {
2379    pub(crate) fn new(config: &'a EncodedJitConfig) -> Self {
2380        Self { config }
2381    }
2382
2383    /// Consuming the handoff prevents the same token being delivered twice by one call.
2384    pub fn consume(self) -> &'a EncodedJitConfig {
2385        self.config
2386    }
2387}
2388
2389/// Provider lifecycle and native process supervision. The default lifecycle
2390/// methods adapt the existing native spawn/recovery implementation, and refuse
2391/// every isolated allocation. Future platform adapters must implement their
2392/// own prepare/start/inspect/stop/destroy paths rather than fall back here.
2393pub trait ExecutionProvider: fmt::Debug + Send + Sync {
2394    fn probe(&self, policy: &ScalePolicy) -> ProviderCapability {
2395        if policy.execution_policy().is_native() {
2396            ProviderCapability::Ready
2397        } else {
2398            ProviderCapability::Unsupported
2399        }
2400    }
2401
2402    /// Resolve Auto and image identity before allocation or GitHub JIT. Native has no image.
2403    fn resolve(&self, policy: &ScalePolicy) -> Result<Option<ResolvedEnvironment>, FailureReason> {
2404        if policy.execution_policy().is_native() {
2405            Ok(None)
2406        } else {
2407            Err(FailureReason::Other(
2408                "execution provider unavailable".into(),
2409            ))
2410        }
2411    }
2412
2413    fn prepare(
2414        &self,
2415        attempt: &RunnerAttempt,
2416        policy: &ScalePolicy,
2417    ) -> Result<PreparedEnvironment, FailureReason> {
2418        if !attempt.execution().is_native() || !policy.execution_policy().is_native() {
2419            return Err(FailureReason::Other(
2420                "execution provider unavailable".into(),
2421            ));
2422        }
2423        Ok(PreparedEnvironment {
2424            attempt: attempt.id,
2425            identity: None,
2426            cache_target: None,
2427        })
2428    }
2429
2430    fn start(
2431        &self,
2432        prepared: PreparedEnvironment,
2433        attempt: &RunnerAttempt,
2434        handoff: OneTimeJitHandoff<'_>,
2435    ) -> Result<EnvironmentIdentity, ProcessStartFailure> {
2436        if prepared.attempt != attempt.id || !attempt.execution().is_native() {
2437            return Err(ProcessStartFailure::before_spawn(FailureReason::Other(
2438                "execution provider identity mismatch".into(),
2439            )));
2440        }
2441        self.spawn(attempt, handoff.consume())
2442            .map(EnvironmentIdentity::NativeProcess)
2443    }
2444
2445    fn inspect(&self, attempt: &RunnerAttempt) -> Result<EnvironmentState, FailureReason> {
2446        if !attempt.execution().is_native() {
2447            return Err(FailureReason::Other(
2448                "execution provider unavailable".into(),
2449            ));
2450        }
2451        self.is_alive(attempt).map(|alive| {
2452            if alive {
2453                EnvironmentState::Running
2454            } else {
2455                EnvironmentState::Missing
2456            }
2457        })
2458    }
2459
2460    fn stop(&self, attempt: &RunnerAttempt) -> Result<ProviderStop, FailureReason> {
2461        if !attempt.execution().is_native() {
2462            return Err(FailureReason::Other(
2463                "execution provider unavailable".into(),
2464            ));
2465        }
2466        self.terminate(attempt).map(|()| ProviderStop::Stopped)
2467    }
2468
2469    fn destroy(&self, attempt: &RunnerAttempt) -> Result<ProviderDestroy, FailureReason> {
2470        if !attempt.execution().is_native() {
2471            return Err(FailureReason::Other(
2472                "execution provider unavailable".into(),
2473            ));
2474        }
2475        // Native termination is already settled by the existing recovery
2476        // decision before workspace cleanup. There is no separate sandbox
2477        // resource to destroy, and repeating an observation here changes the
2478        // established native cleanup behaviour.
2479        Ok(ProviderDestroy::Destroyed)
2480    }
2481
2482    fn recover(&self, attempt: &RunnerAttempt) -> Result<EnvironmentState, FailureReason> {
2483        self.inspect(attempt)
2484    }
2485
2486    fn enumerate_owned(&self, _host_id: HostId) -> Vec<EnvironmentIdentity> {
2487        Vec::new() // Native processes have no independent provider resources.
2488    }
2489
2490    fn diagnostics(&self, _attempt: &RunnerAttempt) -> Vec<ProviderDiagnostic> {
2491        Vec::new() // No job or JIT output enters provider metadata.
2492    }
2493
2494    /// Independent resource metadata must match the journal before destructive calls.
2495    fn owns(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
2496        Ok(attempt.execution().is_native())
2497    }
2498
2499    fn spawn(
2500        &self,
2501        attempt: &RunnerAttempt,
2502        config: &EncodedJitConfig,
2503    ) -> Result<u32, ProcessStartFailure>;
2504    fn is_alive(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason>;
2505    /// Durable identity observed for a process that spawned before the
2506    /// `starting` journal write survived.
2507    fn recovered_pid(&self, attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason>;
2508    /// True only for a child this invocation owned and reaped with a successful
2509    /// exit status.  A recovered process that is merely gone answers false.
2510    fn completed_successfully(&self, attempt: &RunnerAttempt) -> bool;
2511    fn record_terminate_intent(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason>;
2512    fn has_terminate_intent(&self, attempt: &RunnerAttempt) -> bool;
2513    fn terminate(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason>;
2514}
2515
2516/// Compatibility name for existing native process tests and callers.
2517pub use ExecutionProvider as ProcessSupervisor;
2518
2519/// Native process supervision.  The start token is stored beside the runtime;
2520/// recovery never trusts a recycled PID merely because SQLite contains it.
2521#[derive(Debug, Default)]
2522pub struct NativeProcesses {
2523    children: Mutex<BTreeMap<AttemptId, ChildProcess>>,
2524    successful_exits: Mutex<BTreeMap<AttemptId, bool>>,
2525    /// The host's `runner.env`, read at every launch so that `host env set`
2526    /// reaches the next runner without a daemon restart. `None` applies none.
2527    runner_env_file: Option<PathBuf>,
2528    /// Persistent dependency caches. `None` gives runners none.
2529    dependency_caches: Option<DependencyCaches>,
2530    docker_cleanup: Option<crate::docker_cleanup::DockerCleanup>,
2531    #[cfg(test)]
2532    post_spawn_faults: Mutex<VecDeque<PostSpawnBoundary>>,
2533    #[cfg(test)]
2534    post_spawn_reaps: std::sync::atomic::AtomicUsize,
2535    #[cfg(test)]
2536    post_spawn_stop_failures: std::sync::atomic::AtomicUsize,
2537    #[cfg(test)]
2538    use_long_lived_test_listener: std::sync::atomic::AtomicBool,
2539}
2540
2541/// Production provider set. Native and isolated allocations are routed by the
2542/// immutable execution kind in the policy/journal; an unavailable isolated
2543/// backend can never fall through to native process launch.
2544#[derive(Debug)]
2545pub struct PlatformExecutionProvider {
2546    native: NativeProcesses,
2547    oci: OciProcesses,
2548    windows_hyper_v: WindowsHyperVContainers,
2549}
2550
2551impl PlatformExecutionProvider {
2552    #[must_use]
2553    pub fn new(host_id: HostId) -> Self {
2554        Self {
2555            native: NativeProcesses::new(),
2556            oci: OciProcesses::new(host_id),
2557            windows_hyper_v: WindowsHyperVContainers::new(host_id),
2558        }
2559    }
2560
2561    /// Applies the host's `runner.env` at `path` to every native runner.
2562    /// Isolated runners do not get it: their environment is the image's.
2563    #[must_use]
2564    pub fn with_runner_env_file(mut self, path: PathBuf) -> Self {
2565        self.native = self.native.with_runner_env_file(path);
2566        self
2567    }
2568
2569    /// Gives every native runner persistent dependency caches. Isolated
2570    /// runners do not get them: their providers mount no host directory.
2571    #[must_use]
2572    pub fn with_dependency_caches(mut self, caches: DependencyCaches) -> Self {
2573        self.native = self.native.with_dependency_caches(caches);
2574        self
2575    }
2576
2577    #[must_use]
2578    pub fn with_docker_cleanup(mut self, cleanup: crate::docker_cleanup::DockerCleanup) -> Self {
2579        self.native.docker_cleanup = Some(cleanup);
2580        self
2581    }
2582
2583    fn isolated_provider(&self, backend: Backend) -> Option<&dyn ExecutionProvider> {
2584        match backend {
2585            Backend::Auto => {
2586                #[cfg(target_os = "windows")]
2587                {
2588                    Some(&self.windows_hyper_v)
2589                }
2590                #[cfg(not(target_os = "windows"))]
2591                {
2592                    Some(&self.oci)
2593                }
2594            }
2595            Backend::Oci => Some(&self.oci),
2596            Backend::WindowsHyperVContainer => Some(&self.windows_hyper_v),
2597            Backend::VirtualMachine => None,
2598        }
2599    }
2600
2601    fn provider_for_attempt(&self, attempt: &RunnerAttempt) -> Option<&dyn ExecutionProvider> {
2602        match attempt.execution() {
2603            AttemptExecution::Native { .. } => Some(&self.native),
2604            AttemptExecution::Isolated { provider_kind, .. } => {
2605                self.isolated_provider(*provider_kind)
2606            }
2607        }
2608    }
2609
2610    fn required_provider_for_attempt(
2611        &self,
2612        attempt: &RunnerAttempt,
2613    ) -> Result<&dyn ExecutionProvider, FailureReason> {
2614        self.provider_for_attempt(attempt)
2615            .ok_or_else(|| FailureReason::Other("execution provider unavailable".into()))
2616    }
2617}
2618
2619impl ExecutionProvider for PlatformExecutionProvider {
2620    fn probe(&self, policy: &ScalePolicy) -> ProviderCapability {
2621        match policy.execution_policy() {
2622            runner_manager_domain::execution::ExecutionPolicy::Native => self.native.probe(policy),
2623            runner_manager_domain::execution::ExecutionPolicy::Isolated { backend, .. } => self
2624                .isolated_provider(*backend)
2625                .map_or(ProviderCapability::Unsupported, |provider| {
2626                    provider.probe(policy)
2627                }),
2628        }
2629    }
2630
2631    fn resolve(&self, policy: &ScalePolicy) -> Result<Option<ResolvedEnvironment>, FailureReason> {
2632        match policy.execution_policy() {
2633            runner_manager_domain::execution::ExecutionPolicy::Native => {
2634                self.native.resolve(policy)
2635            }
2636            runner_manager_domain::execution::ExecutionPolicy::Isolated { backend, .. } => self
2637                .isolated_provider(*backend)
2638                .ok_or_else(|| FailureReason::Other("execution provider unavailable".into()))?
2639                .resolve(policy),
2640        }
2641    }
2642
2643    fn prepare(
2644        &self,
2645        attempt: &RunnerAttempt,
2646        policy: &ScalePolicy,
2647    ) -> Result<PreparedEnvironment, FailureReason> {
2648        match policy.execution_policy() {
2649            runner_manager_domain::execution::ExecutionPolicy::Native => {
2650                self.native.prepare(attempt, policy)
2651            }
2652            runner_manager_domain::execution::ExecutionPolicy::Isolated { backend, .. } => self
2653                .isolated_provider(*backend)
2654                .ok_or_else(|| FailureReason::Other("execution provider unavailable".into()))?
2655                .prepare(attempt, policy),
2656        }
2657    }
2658
2659    fn start(
2660        &self,
2661        prepared: PreparedEnvironment,
2662        attempt: &RunnerAttempt,
2663        handoff: OneTimeJitHandoff<'_>,
2664    ) -> Result<EnvironmentIdentity, ProcessStartFailure> {
2665        self.provider_for_attempt(attempt)
2666            .ok_or_else(|| {
2667                ProcessStartFailure::before_spawn(FailureReason::Other(
2668                    "execution provider unavailable".into(),
2669                ))
2670            })?
2671            .start(prepared, attempt, handoff)
2672    }
2673
2674    fn inspect(&self, attempt: &RunnerAttempt) -> Result<EnvironmentState, FailureReason> {
2675        self.required_provider_for_attempt(attempt)?
2676            .inspect(attempt)
2677    }
2678
2679    fn stop(&self, attempt: &RunnerAttempt) -> Result<ProviderStop, FailureReason> {
2680        self.required_provider_for_attempt(attempt)?.stop(attempt)
2681    }
2682
2683    fn destroy(&self, attempt: &RunnerAttempt) -> Result<ProviderDestroy, FailureReason> {
2684        self.required_provider_for_attempt(attempt)?
2685            .destroy(attempt)
2686    }
2687
2688    fn recover(&self, attempt: &RunnerAttempt) -> Result<EnvironmentState, FailureReason> {
2689        self.required_provider_for_attempt(attempt)?
2690            .recover(attempt)
2691    }
2692
2693    fn enumerate_owned(&self, host_id: HostId) -> Vec<EnvironmentIdentity> {
2694        let mut owned = self.oci.enumerate_owned(host_id);
2695        owned.extend(self.windows_hyper_v.enumerate_owned(host_id));
2696        owned
2697    }
2698
2699    fn diagnostics(&self, attempt: &RunnerAttempt) -> Vec<ProviderDiagnostic> {
2700        self.provider_for_attempt(attempt)
2701            .map_or_else(Vec::new, |provider| provider.diagnostics(attempt))
2702    }
2703
2704    fn owns(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
2705        self.required_provider_for_attempt(attempt)?.owns(attempt)
2706    }
2707
2708    fn spawn(
2709        &self,
2710        attempt: &RunnerAttempt,
2711        config: &EncodedJitConfig,
2712    ) -> Result<u32, ProcessStartFailure> {
2713        if !attempt.execution().is_native() {
2714            return Err(ProcessStartFailure::before_spawn(FailureReason::Other(
2715                "native process launch refused for an isolated allocation".into(),
2716            )));
2717        }
2718        self.native.spawn(attempt, config)
2719    }
2720
2721    fn is_alive(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
2722        self.required_provider_for_attempt(attempt)?
2723            .is_alive(attempt)
2724    }
2725
2726    fn recovered_pid(&self, attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason> {
2727        self.required_provider_for_attempt(attempt)?
2728            .recovered_pid(attempt)
2729    }
2730
2731    fn completed_successfully(&self, attempt: &RunnerAttempt) -> bool {
2732        self.provider_for_attempt(attempt)
2733            .is_some_and(|provider| provider.completed_successfully(attempt))
2734    }
2735
2736    fn record_terminate_intent(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason> {
2737        self.required_provider_for_attempt(attempt)?
2738            .record_terminate_intent(attempt)
2739    }
2740
2741    fn has_terminate_intent(&self, attempt: &RunnerAttempt) -> bool {
2742        self.provider_for_attempt(attempt)
2743            .is_some_and(|provider| provider.has_terminate_intent(attempt))
2744    }
2745
2746    fn terminate(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason> {
2747        self.required_provider_for_attempt(attempt)?
2748            .terminate(attempt)
2749    }
2750}
2751
2752#[cfg(test)]
2753#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2754enum PostSpawnBoundary {
2755    HandoffDelete,
2756    IdentitySerialize,
2757    IdentityWrite,
2758    ChildMapInsert,
2759}
2760
2761impl NativeProcesses {
2762    #[must_use]
2763    pub fn new() -> Self {
2764        Self::default()
2765    }
2766
2767    /// Applies the host's `runner.env` at `path` to every runner launched.
2768    #[must_use]
2769    pub fn with_runner_env_file(mut self, path: PathBuf) -> Self {
2770        self.runner_env_file = Some(path);
2771        self
2772    }
2773
2774    /// Gives every runner launched persistent dependency caches.
2775    #[must_use]
2776    pub fn with_dependency_caches(mut self, caches: DependencyCaches) -> Self {
2777        self.dependency_caches = Some(caches);
2778        self
2779    }
2780
2781    /// The cache variables for `attempt`, when `prepare` named its target.
2782    fn cache_env(
2783        &self,
2784        attempt: &RunnerAttempt,
2785        target: Option<&runner_manager_domain::model::ScaleTarget>,
2786        host_env: &RunnerEnv,
2787    ) -> Vec<(&'static str, std::ffi::OsString)> {
2788        match (&self.dependency_caches, target) {
2789            (Some(caches), Some(target)) => {
2790                caches.for_launch(target, attempt.runtime_path(), host_env)
2791            }
2792            _ => Vec::new(),
2793        }
2794    }
2795
2796    /// Starts `Runner.Listener` for `attempt`, with the dependency caches of
2797    /// `cache_target` when there is one.
2798    fn spawn_listener(
2799        &self,
2800        attempt: &RunnerAttempt,
2801        config: &EncodedJitConfig,
2802        cache_target: Option<&runner_manager_domain::model::ScaleTarget>,
2803    ) -> Result<u32, ProcessStartFailure> {
2804        let handoff = RestrictiveHandoff::create(
2805            attempt.runtime_path(),
2806            SecretString::from(config.expose().to_owned()),
2807        )
2808        .map_err(|_| ProcessStartFailure::before_spawn(FailureReason::ProcessStartFailed))?;
2809        #[cfg(windows)]
2810        let program = attempt
2811            .runtime_path()
2812            .join("bin")
2813            .join("Runner.Listener.exe");
2814        #[cfg(not(windows))]
2815        let program = attempt.runtime_path().join("bin").join("Runner.Listener");
2816        // Checked after the handoff exists on purpose: the error path below is
2817        // a real post-handoff launch failure, and unwinding must delete it.
2818        if !program.is_file() {
2819            return Err(ProcessStartFailure::before_spawn(
2820                FailureReason::ProcessStartFailed,
2821            ));
2822        }
2823        let host_env = self.host_env().map_err(ProcessStartFailure::before_spawn)?;
2824        let caches = self.cache_env(attempt, cache_target, &host_env);
2825        #[cfg(test)]
2826        let spec = if self
2827            .use_long_lived_test_listener
2828            .load(std::sync::atomic::Ordering::SeqCst)
2829        {
2830            SpawnSpec::new(program)
2831                .args([
2832                    "--ignored",
2833                    "--exact",
2834                    "lifecycle::tests::long_lived_native_listener_helper",
2835                    "--nocapture",
2836                ])
2837                .env(
2838                    "RUNNER_MANAGER_TEST_LISTENER_READY",
2839                    attempt.runtime_path().join(TEST_LISTENER_READY),
2840                )
2841                .working_dir(attempt.runtime_path())
2842        } else {
2843            runner_listener_spec(program, attempt.runtime_path(), caches, &host_env)
2844        };
2845        #[cfg(not(test))]
2846        let spec = runner_listener_spec(program, attempt.runtime_path(), caches, &host_env);
2847        let (spec, docker_proxy) = if let Some(cleanup) = &self.docker_cleanup {
2848            let repository = cache_target.map(ToString::to_string).unwrap_or_default();
2849            match cleanup.launch_environment(attempt.id, &repository, &host_env) {
2850                Ok(Some(endpoint)) => (
2851                    spec.env("DOCKER_HOST", endpoint),
2852                    Some(cleanup.root.join(attempt.id.to_string())),
2853                ),
2854                Ok(None) => (spec, None),
2855                Err(error) => {
2856                    tracing::warn!(attempt_id = %attempt.id, event = "docker_accounting_unavailable", reason = ?error.kind(), "native Docker accounting could not start");
2857                    if crate::docker_cleanup::Config::load(&cleanup.config).map_or(true, |config| {
2858                        config.mode == crate::docker_cleanup::Mode::Apply
2859                    }) {
2860                        return Err(ProcessStartFailure::before_spawn(
2861                            FailureReason::ProcessStartFailed,
2862                        ));
2863                    }
2864                    (spec, None)
2865                }
2866            }
2867        } else {
2868            (spec, None)
2869        };
2870        let child = spec
2871            .spawn_runner_with_handoff(&handoff)
2872            .map_err(|_| ProcessStartFailure::before_spawn(FailureReason::ProcessStartFailed))?;
2873        // The payload is gone before any state saying "starting" is persisted.
2874        #[cfg(test)]
2875        if self.faults_at(PostSpawnBoundary::HandoffDelete) {
2876            drop(handoff);
2877            return Err(self.abort_spawned_child(child, attempt, false));
2878        }
2879        if handoff.delete().is_err() {
2880            return Err(self.abort_spawned_child(child, attempt, false));
2881        }
2882        #[cfg(test)]
2883        if self.faults_at(PostSpawnBoundary::IdentitySerialize) {
2884            return Err(self.abort_spawned_child(child, attempt, false));
2885        }
2886        let identity = match serde_json::to_vec(child.identity()) {
2887            Ok(identity) => identity,
2888            Err(_) => {
2889                return Err(self.abort_spawned_child(child, attempt, false));
2890            }
2891        };
2892        if self.persist_identity(attempt, &identity).is_err() {
2893            return Err(self.abort_spawned_child(child, attempt, true));
2894        }
2895        if let Some(directory) = docker_proxy
2896            && crate::docker_cleanup::write_json(&directory.join("runner.json"), child.identity())
2897                .is_err()
2898        {
2899            return Err(self.abort_spawned_child(child, attempt, true));
2900        }
2901        let pid = child.pid();
2902        #[cfg(test)]
2903        if self.faults_at(PostSpawnBoundary::ChildMapInsert) {
2904            return Err(self.abort_spawned_child(child, attempt, true));
2905        }
2906        let mut children = self
2907            .children
2908            .lock()
2909            .unwrap_or_else(std::sync::PoisonError::into_inner);
2910        children.insert(attempt.id, child);
2911        Ok(pid)
2912    }
2913
2914    /// The host's `runner.env`, read now.
2915    ///
2916    /// A file that cannot be read or parsed fails the launch rather than
2917    /// starting a runner without the environment the operator configured.
2918    /// [`ExecutionProvider::prepare`] asks first, before a JIT runner is
2919    /// registered, so a broken file costs no registration. The reason names
2920    /// the file and the line or variable, never a value.
2921    fn host_env(&self) -> Result<RunnerEnv, FailureReason> {
2922        let Some(path) = &self.runner_env_file else {
2923            return Ok(RunnerEnv::default());
2924        };
2925        RunnerEnv::load(path).map_err(|error| {
2926            tracing::warn!(
2927                reason = "runner_env_file_invalid",
2928                "runner.env could not be applied, so no runner starts until it is fixed: {error}"
2929            );
2930            FailureReason::Other(format!(
2931                "runner.env cannot be applied ({error}); fix it with `runner-manager host env`"
2932            ))
2933        })
2934    }
2935
2936    #[cfg(test)]
2937    fn fail_post_spawn_at(&self, boundary: PostSpawnBoundary) {
2938        self.post_spawn_faults.lock().unwrap().push_back(boundary);
2939    }
2940
2941    #[cfg(test)]
2942    fn faults_at(&self, boundary: PostSpawnBoundary) -> bool {
2943        let mut faults = self.post_spawn_faults.lock().unwrap();
2944        if faults.front() == Some(&boundary) {
2945            faults.pop_front();
2946            true
2947        } else {
2948            false
2949        }
2950    }
2951
2952    #[cfg(test)]
2953    fn fail_post_spawn_stops(&self, count: usize) {
2954        self.post_spawn_stop_failures
2955            .fetch_add(count, std::sync::atomic::Ordering::SeqCst);
2956    }
2957
2958    #[cfg(test)]
2959    fn fail_next_post_spawn_stop(&self) {
2960        self.fail_post_spawn_stops(1);
2961    }
2962
2963    #[cfg(test)]
2964    fn use_long_lived_test_listener(&self) {
2965        self.use_long_lived_test_listener
2966            .store(true, std::sync::atomic::Ordering::SeqCst);
2967    }
2968
2969    fn stop_spawned_child(&self, child: &mut ChildProcess) -> Result<(), FailureReason> {
2970        #[cfg(test)]
2971        if self
2972            .post_spawn_stop_failures
2973            .fetch_update(
2974                std::sync::atomic::Ordering::SeqCst,
2975                std::sync::atomic::Ordering::SeqCst,
2976                |left| if left > 0 { Some(left - 1) } else { None },
2977            )
2978            .is_ok()
2979        {
2980            return Err(FailureReason::Other("injected runner stop failure".into()));
2981        }
2982        child
2983            .stop(Duration::from_secs(1))
2984            .map(|_| ())
2985            .map_err(|_| FailureReason::Other("spawned runner process could not be stopped".into()))
2986    }
2987
2988    fn abort_spawned_child(
2989        &self,
2990        mut child: ChildProcess,
2991        attempt: &RunnerAttempt,
2992        remove_identity: bool,
2993    ) -> ProcessStartFailure {
2994        let mut reaped = self.stop_spawned_child(&mut child).is_ok();
2995        if reaped {
2996            if remove_identity {
2997                Self::remove_identity_files(attempt);
2998            }
2999        } else {
3000            // A failed stop is not a failed attempt yet.  Persist enough truth
3001            // for crash recovery, and retain the owned child when possible.
3002            let identity_durable =
3003                serde_json::to_vec(child.identity())
3004                    .ok()
3005                    .is_some_and(|identity| {
3006                        self.persist_identity(attempt, &identity).is_ok()
3007                            || self.persist_fallback_identity(attempt, &identity).is_ok()
3008                    });
3009            if !identity_durable {
3010                // Returning a live PID as though recovery were complete would
3011                // make the next boot trust a recyclable PID. Reaping is bounded;
3012                // if it cannot finish, the durable `starting` journal entry is
3013                // deliberately unresolved on restart and blocks new launches.
3014                for _ in 1..MAX_POST_SPAWN_STOP_ATTEMPTS {
3015                    if self.stop_spawned_child(&mut child).is_ok() {
3016                        reaped = true;
3017                        break;
3018                    }
3019                }
3020                if !reaped {
3021                    // The attempt journal will durably record `starting` and
3022                    // its PID. Recovery treats a missing full identity as
3023                    // unresolved and starts nothing, so bounded stop failure
3024                    // cannot turn into either a hang or a duplicate runner.
3025                    let pid = child.pid();
3026                    let marker = write_durable_file(
3027                        &Self::unresolved_process_path(attempt),
3028                        pid.to_string().as_bytes(),
3029                    );
3030                    self.children
3031                        .lock()
3032                        .unwrap_or_else(std::sync::PoisonError::into_inner)
3033                        .insert(attempt.id, child);
3034                    let reason = if marker.is_ok() {
3035                        FailureReason::Other(
3036                            "spawn cleanup exhausted its bounded stop attempts; the live process remains under durable unresolved supervision"
3037                                .into(),
3038                        )
3039                    } else {
3040                        FailureReason::Other(
3041                            "spawn cleanup exhausted its bounded stop attempts and the unresolved-process marker could not be journalled"
3042                                .into(),
3043                        )
3044                    };
3045                    return ProcessStartFailure::after_spawn_live_with_reason(pid, reason);
3046                }
3047            } else {
3048                let pid = child.pid();
3049                self.children
3050                    .lock()
3051                    .unwrap_or_else(std::sync::PoisonError::into_inner)
3052                    .insert(attempt.id, child);
3053                return ProcessStartFailure::after_spawn_live(pid);
3054            }
3055        }
3056        #[cfg(test)]
3057        if reaped {
3058            self.post_spawn_reaps
3059                .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
3060        }
3061        #[cfg(not(test))]
3062        let _ = reaped;
3063        ProcessStartFailure::after_spawn_stopped()
3064    }
3065
3066    fn identity_path(attempt: &RunnerAttempt) -> PathBuf {
3067        attempt.runtime_path().join(IDENTITY_FILE)
3068    }
3069
3070    fn fallback_identity_path(attempt: &RunnerAttempt) -> PathBuf {
3071        attempt.runtime_path().join(FALLBACK_IDENTITY_FILE)
3072    }
3073
3074    fn unresolved_process_path(attempt: &RunnerAttempt) -> PathBuf {
3075        attempt.runtime_path().join(UNRESOLVED_PROCESS_FILE)
3076    }
3077
3078    fn remove_identity_files(attempt: &RunnerAttempt) {
3079        let _ = fs::remove_file(Self::identity_path(attempt));
3080        let _ = fs::remove_file(Self::fallback_identity_path(attempt));
3081        let _ = fs::remove_file(Self::unresolved_process_path(attempt));
3082    }
3083
3084    fn persist_identity(&self, attempt: &RunnerAttempt, bytes: &[u8]) -> std::io::Result<()> {
3085        self.persist_identity_at(&Self::identity_path(attempt), bytes)
3086    }
3087
3088    fn persist_fallback_identity(
3089        &self,
3090        attempt: &RunnerAttempt,
3091        bytes: &[u8],
3092    ) -> std::io::Result<()> {
3093        self.persist_identity_at(&Self::fallback_identity_path(attempt), bytes)
3094    }
3095
3096    fn persist_identity_at(&self, path: &Path, bytes: &[u8]) -> std::io::Result<()> {
3097        #[cfg(test)]
3098        if self.faults_at(PostSpawnBoundary::IdentityWrite) {
3099            return Err(std::io::Error::other("injected identity write failure"));
3100        }
3101        write_durable_file(path, bytes)
3102    }
3103
3104    fn intent_path(attempt: &RunnerAttempt) -> PathBuf {
3105        attempt.runtime_path().join(TERMINATE_INTENT_FILE)
3106    }
3107
3108    fn read_identity(attempt: &RunnerAttempt) -> Result<Option<ProcessIdentity>, FailureReason> {
3109        match Self::read_identity_at(&Self::identity_path(attempt))? {
3110            Some(identity) => Ok(Some(identity)),
3111            None => Self::read_identity_at(&Self::fallback_identity_path(attempt)),
3112        }
3113    }
3114
3115    fn read_identity_at(path: &Path) -> Result<Option<ProcessIdentity>, FailureReason> {
3116        match fs::read(path) {
3117            Ok(bytes) => serde_json::from_slice(&bytes)
3118                .map(Some)
3119                .map_err(|_| FailureReason::Other("process identity journal is unreadable".into())),
3120            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
3121            Err(_) => Err(FailureReason::Other(
3122                "process identity journal could not be read".into(),
3123            )),
3124        }
3125    }
3126}
3127
3128impl ProcessSupervisor for NativeProcesses {
3129    fn destroy(&self, attempt: &RunnerAttempt) -> Result<ProviderDestroy, FailureReason> {
3130        if !attempt.execution().is_native() {
3131            return Err(FailureReason::Other(
3132                "execution provider unavailable".into(),
3133            ));
3134        }
3135        // Existing native reconciliation has already settled process termination
3136        // before this boundary. Capture before ephemeral workspace deletion.
3137        if let Some(caches) = &self.dependency_caches {
3138            caches.capture_git(attempt.runtime_path());
3139        }
3140        Ok(ProviderDestroy::Destroyed)
3141    }
3142
3143    fn prepare(
3144        &self,
3145        attempt: &RunnerAttempt,
3146        policy: &ScalePolicy,
3147    ) -> Result<PreparedEnvironment, FailureReason> {
3148        if !attempt.execution().is_native() || !policy.execution_policy().is_native() {
3149            return Err(FailureReason::Other(
3150                "execution provider unavailable".into(),
3151            ));
3152        }
3153        self.host_env()?;
3154        // Asked here, before the JIT registration, for the reason `host_env`
3155        // is: a broken file must cost no registration.
3156        let cache_target = match &self.dependency_caches {
3157            Some(caches) => {
3158                caches.config()?;
3159                Some(policy.target.clone())
3160            }
3161            None => None,
3162        };
3163        Ok(PreparedEnvironment {
3164            attempt: attempt.id,
3165            identity: None,
3166            cache_target,
3167        })
3168    }
3169
3170    fn start(
3171        &self,
3172        prepared: PreparedEnvironment,
3173        attempt: &RunnerAttempt,
3174        handoff: OneTimeJitHandoff<'_>,
3175    ) -> Result<EnvironmentIdentity, ProcessStartFailure> {
3176        if prepared.attempt != attempt.id || !attempt.execution().is_native() {
3177            return Err(ProcessStartFailure::before_spawn(FailureReason::Other(
3178                "execution provider identity mismatch".into(),
3179            )));
3180        }
3181        self.spawn_listener(attempt, handoff.consume(), prepared.cache_target.as_ref())
3182            .map(EnvironmentIdentity::NativeProcess)
3183    }
3184
3185    fn spawn(
3186        &self,
3187        attempt: &RunnerAttempt,
3188        config: &EncodedJitConfig,
3189    ) -> Result<u32, ProcessStartFailure> {
3190        self.spawn_listener(attempt, config, None)
3191    }
3192
3193    fn is_alive(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
3194        let mut children = self
3195            .children
3196            .lock()
3197            .unwrap_or_else(std::sync::PoisonError::into_inner);
3198        if let Some(child) = children.get_mut(&attempt.id) {
3199            return match child
3200                .try_exit_status()
3201                .map_err(|_| FailureReason::Other("runner process could not be observed".into()))?
3202            {
3203                None => Ok(true),
3204                Some(status) => {
3205                    if let Ok(mut exits) = self.successful_exits.lock() {
3206                        exits.insert(attempt.id, status.success());
3207                    }
3208                    Ok(false)
3209                }
3210            };
3211        }
3212        let Some(identity) = Self::read_identity(attempt)? else {
3213            if attempt.process_id().is_some() || Self::unresolved_process_path(attempt).is_file() {
3214                return Err(FailureReason::Other(
3215                    "runner process identity is missing; refusing recovery until the process is resolved"
3216                        .into(),
3217                ));
3218            }
3219            return Ok(false);
3220        };
3221        match identity.recheck() {
3222            Ok(Adoption::Live) => Ok(true),
3223            Ok(Adoption::Gone | Adoption::PidRecycled { .. }) => Ok(false),
3224            Err(_) => Ok(false),
3225        }
3226    }
3227
3228    fn recovered_pid(&self, attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason> {
3229        Ok(Self::read_identity(attempt)?.map(|identity| identity.pid()))
3230    }
3231
3232    fn completed_successfully(&self, attempt: &RunnerAttempt) -> bool {
3233        self.successful_exits
3234            .lock()
3235            .ok()
3236            .and_then(|exits| exits.get(&attempt.id).copied())
3237            .unwrap_or(false)
3238    }
3239
3240    fn record_terminate_intent(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason> {
3241        let path = Self::intent_path(attempt);
3242        write_durable_file(&path, b"registration-timeout\n")
3243            .map_err(|_| FailureReason::Other("terminate intent could not be journalled".into()))
3244    }
3245
3246    fn has_terminate_intent(&self, attempt: &RunnerAttempt) -> bool {
3247        Self::intent_path(attempt).is_file()
3248    }
3249
3250    fn terminate(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason> {
3251        let mut children = self
3252            .children
3253            .lock()
3254            .unwrap_or_else(std::sync::PoisonError::into_inner);
3255        if let Some(child) = children.get_mut(&attempt.id) {
3256            child
3257                .stop(Duration::from_secs(10))
3258                .map_err(|_| FailureReason::Other("runner process could not be stopped".into()))?;
3259            return Ok(());
3260        }
3261        let Some(identity) = Self::read_identity(attempt)? else {
3262            return Ok(());
3263        };
3264        match identity
3265            .terminate(Duration::from_secs(10))
3266            .map_err(|_| FailureReason::Other("runner process could not be stopped".into()))?
3267        {
3268            Termination::Terminated | Termination::AlreadyGone => Ok(()),
3269            Termination::RefusedPidRecycled { .. } => Err(FailureReason::Other(
3270                "runner PID was recycled; refusing to signal it".into(),
3271            )),
3272        }
3273    }
3274}
3275
3276pub struct LifecyclePorts {
3277    pub store: Arc<dyn Store>,
3278    pub github: Arc<dyn LifecycleGithub>,
3279    pub packages: Arc<dyn RuntimePackages>,
3280    pub processes: Arc<dyn ExecutionProvider>,
3281    pub clock: Arc<dyn Clock>,
3282    pub demand: Arc<dyn DemandPersistence>,
3283    pub delay: Arc<dyn RetryDelay>,
3284    pub events: Arc<dyn AttemptEventSink>,
3285    pub reconcile_events: Arc<dyn EventSink>,
3286}
3287
3288impl fmt::Debug for LifecyclePorts {
3289    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3290        f.debug_struct("LifecyclePorts")
3291            .field("store", &self.store)
3292            .field("github", &self.github)
3293            .field("packages", &self.packages)
3294            .field("processes", &self.processes)
3295            .finish_non_exhaustive()
3296    }
3297}
3298
3299#[derive(Debug, thiserror::Error)]
3300pub enum LifecycleError {
3301    #[error("attempt journal operation failed")]
3302    Journal,
3303    #[error("attempt {0} is not in the journal")]
3304    Missing(AttemptId),
3305    #[error("attempt lifecycle transition was refused")]
3306    Transition,
3307    #[error("startup recovery has not completed")]
3308    RecoveryIncomplete,
3309    /// A persistent slot could not be proven safe to scrub, so the attempt keeps
3310    /// its uncleaned state and, with it, its slot lease.
3311    ///
3312    /// Separate from [`Self::Failed`] because the two demand opposite handling
3313    /// from the same call sites. A failure aborts the pass; a quarantine must
3314    /// not, or one stuck slot would stop the host launching anything at all —
3315    /// which is precisely what `04-security-recovery.md` rules out when it says
3316    /// such an attempt "does not count as active host capacity" and "recovery
3317    /// retries the same cleanup".
3318    #[error("the persistent slot was not cleaned: {detail}")]
3319    SlotQuarantined {
3320        /// The closed-vocabulary event field; never operator or workflow text.
3321        class: &'static str,
3322        detail: String,
3323    },
3324    /// An ephemeral runtime is still held by a late process. Its journal state
3325    /// remains unchanged and ordinary supervision retries it, but startup
3326    /// recovery must not take unrelated repositories offline because of it.
3327    #[error("{}", FailureReason::WorkspaceCleanupDeferred)]
3328    WorkspaceCleanupDeferred,
3329    #[error("isolated environment cleanup is deferred")]
3330    EnvironmentCleanupDeferred,
3331    #[error("runner lifecycle failed: {0}")]
3332    Failed(FailureReason),
3333}
3334
3335impl LifecycleError {
3336    fn reason(&self) -> FailureReason {
3337        match self {
3338            Self::Failed(reason) => reason.clone(),
3339            Self::RecoveryIncomplete => FailureReason::Other("startup recovery incomplete".into()),
3340            Self::Journal => FailureReason::Other("attempt journal operation failed".into()),
3341            Self::Missing(_) => FailureReason::Other("attempt disappeared from the journal".into()),
3342            Self::Transition => FailureReason::Other("attempt transition was refused".into()),
3343            // Rendered through `Display` rather than a second copy of the same
3344            // sentence, so the two cannot drift apart.
3345            Self::SlotQuarantined { .. } => FailureReason::Other(self.to_string()),
3346            Self::WorkspaceCleanupDeferred => FailureReason::WorkspaceCleanupDeferred,
3347            Self::EnvironmentCleanupDeferred => FailureReason::Other(self.to_string()),
3348        }
3349    }
3350}
3351
3352/// Production implementation of e1's launcher port.
3353#[derive(Debug)]
3354pub struct LifecycleLauncher {
3355    host_id: HostId,
3356    app_paths: runner_manager_platform::paths::AppPaths,
3357    diagnostics_root: PathBuf,
3358    runner_group_id: u64,
3359    timeouts: RecoveryTimeouts,
3360    retry: RetryPolicy,
3361    cancel: CancelToken,
3362    ports: LifecyclePorts,
3363    recovery_complete: Mutex<bool>,
3364    versions: Mutex<BTreeMap<AttemptId, RunnerVersion>>,
3365    pending_replacements: Mutex<BTreeMap<AttemptId, ReplacementIntent>>,
3366    /// Backoff for cleanups [`Self::supervise`] tolerates and retries, so a
3367    /// workspace that cannot be removed is not retried and reported on every
3368    /// pass.
3369    cleanup_retries: Mutex<CleanBackoff>,
3370    turns: Arc<tokio::sync::Semaphore>,
3371    /// The attempts reserved and not yet up or failed: their bring-up is writing
3372    /// their journal entries, and it runs while the target's pass supervises.
3373    bringing_up: Mutex<BTreeSet<AttemptId>>,
3374}
3375
3376/// Takes an attempt out of [`LifecycleLauncher::bringing_up`], however its
3377/// bring-up ends.
3378struct BroughtUpOnDrop<'a> {
3379    attempts: &'a Mutex<BTreeSet<AttemptId>>,
3380    id: AttemptId,
3381}
3382
3383impl Drop for BroughtUpOnDrop<'_> {
3384    fn drop(&mut self) {
3385        self.attempts
3386            .lock()
3387            .unwrap_or_else(std::sync::PoisonError::into_inner)
3388            .remove(&self.id);
3389    }
3390}
3391
3392/// How many runners of one host prepare at once, whatever their target: a
3393/// quarter of its cores, at least two. A runner's package is ~9,000 files, and
3394/// on a disk jobs are using, more at once only make all of them slower. Fair,
3395/// so the ones waiting go in the order they were reserved.
3396#[must_use]
3397pub fn preparation_turns() -> Arc<tokio::sync::Semaphore> {
3398    let cores = std::thread::available_parallelism().map_or(1, std::num::NonZero::get);
3399    Arc::new(tokio::sync::Semaphore::new((cores / 4).max(2)))
3400}
3401
3402#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3403enum ReconcileProgress {
3404    Reconciled,
3405    Deferred,
3406    Replacement {
3407        attempt: AttemptId,
3408        operation: &'static str,
3409    },
3410}
3411
3412impl LifecycleLauncher {
3413    #[must_use]
3414    pub fn new(
3415        host_id: HostId,
3416        app_paths: runner_manager_platform::paths::AppPaths,
3417        diagnostics_root: impl Into<PathBuf>,
3418        runner_group_id: u64,
3419        timeouts: RecoveryTimeouts,
3420        retry: RetryPolicy,
3421        ports: LifecyclePorts,
3422    ) -> Self {
3423        Self {
3424            host_id,
3425            app_paths,
3426            diagnostics_root: diagnostics_root.into(),
3427            runner_group_id,
3428            timeouts,
3429            retry,
3430            cancel: CancelToken::new(),
3431            ports,
3432            recovery_complete: Mutex::new(false),
3433            versions: Mutex::new(BTreeMap::new()),
3434            pending_replacements: Mutex::new(BTreeMap::new()),
3435            cleanup_retries: Mutex::new(CleanBackoff::default()),
3436            turns: preparation_turns(),
3437            bringing_up: Mutex::default(),
3438        }
3439    }
3440
3441    /// Takes turns to prepare with every launcher holding `turns`, rather than
3442    /// only with itself: the targets of one host.
3443    #[must_use]
3444    pub fn with_preparation_turns(mut self, turns: Arc<tokio::sync::Semaphore>) -> Self {
3445        self.turns = turns;
3446        self
3447    }
3448
3449    /// Reconcile the entire journal before allowing a launch.  Unknown-policy
3450    /// attempts are left untouched rather than being acted on without an
3451    /// ownership proof.
3452    pub async fn recover_startup(
3453        &self,
3454        policies: &[ScalePolicy],
3455    ) -> Result<Vec<ReplacementIntent>, LifecycleError> {
3456        *self
3457            .recovery_complete
3458            .lock()
3459            .map_err(|_| LifecycleError::Journal)? = false;
3460        let by_id: BTreeMap<_, _> = policies.iter().map(|policy| (policy.id, policy)).collect();
3461        let attempts = self
3462            .ports
3463            .store
3464            .attempts()
3465            .map_err(|_| LifecycleError::Journal)?;
3466        let owned = self.ports.processes.enumerate_owned(self.host_id);
3467        for identity in &owned {
3468            if let EnvironmentIdentity::Isolated { attempt: id, .. } = identity
3469                && !attempts.iter().any(|attempt| {
3470                    attempt.state() != AttemptState::Cleaned
3471                        && identity_matches_intent(self.host_id, attempt, identity)
3472                })
3473            {
3474                // Enumeration is discovery, not authority to delete.
3475                self.ports
3476                    .events
3477                    .emit(AttemptEvent::QuarantinedOwnedResource { attempt: *id });
3478                tracing::warn!(host_id = %self.host_id, attempt_id = %id, reason = "owned_isolated_resource_without_matching_journal", "provider resource quarantined for operator review");
3479            }
3480        }
3481        let mut unresolved = false;
3482        for mut attempt in attempts {
3483            let Some(policy) = by_id.get(&attempt.policy_id) else {
3484                if attempt.counts_against_capacity() {
3485                    unresolved = true;
3486                }
3487                continue;
3488            };
3489            authorize(self.host_id, policy, &attempt).map_err(|_| LifecycleError::Journal)?;
3490            let allocation_resources = owned
3491                .iter()
3492                .filter(|identity| identity_same_allocation(self.host_id, &attempt, identity))
3493                .collect::<Vec<_>>();
3494            if allocation_resources.len() > 1 {
3495                self.ports
3496                    .events
3497                    .emit(AttemptEvent::QuarantinedOwnedResource {
3498                        attempt: attempt.id,
3499                    });
3500                tracing::warn!(attempt_id = %attempt.id, reason = "duplicate_isolated_resource_ownership", "provider resources quarantined for operator review");
3501            }
3502            if attempt.state() != AttemptState::Cleaned
3503                && matches!(
3504                    attempt.execution(),
3505                    AttemptExecution::Isolated {
3506                        environment_id: None,
3507                        ..
3508                    }
3509                )
3510                && allocation_resources.len() == 1
3511                && let Some(EnvironmentIdentity::Isolated { environment_id, .. }) =
3512                    allocation_resources.first().copied()
3513            {
3514                attempt
3515                    .prepared_environment(environment_id.clone())
3516                    .map_err(|_| LifecycleError::Transition)?;
3517                if attempt.state() == AttemptState::Preparing {
3518                    attempt
3519                        .mark_prepared(self.ports.clock.now())
3520                        .map_err(|_| LifecycleError::Transition)?;
3521                }
3522                self.record(&attempt)?;
3523            }
3524            match self.reconcile_one(policy, attempt).await? {
3525                ReconcileProgress::Deferred => {
3526                    if policy.execution_policy().is_native() {
3527                        unresolved = true;
3528                    }
3529                }
3530                ReconcileProgress::Replacement { attempt, operation } => {
3531                    self.pending_replacements
3532                        .lock()
3533                        .map_err(|_| LifecycleError::Journal)?
3534                        .insert(
3535                            attempt,
3536                            ReplacementIntent {
3537                                policy: policy.id,
3538                                previous_attempt: attempt,
3539                                operation,
3540                            },
3541                        );
3542                }
3543                ReconcileProgress::Reconciled => {}
3544            }
3545        }
3546        if unresolved {
3547            return Err(LifecycleError::RecoveryIncomplete);
3548        }
3549        *self
3550            .recovery_complete
3551            .lock()
3552            .map_err(|_| LifecycleError::Journal)? = true;
3553        Ok(self
3554            .pending_replacements
3555            .lock()
3556            .map_err(|_| LifecycleError::Journal)?
3557            .values()
3558            .copied()
3559            .collect())
3560    }
3561
3562    /// Supervise all attempts of one policy during an ordinary poll.
3563    pub async fn supervise(
3564        &self,
3565        policy: &ScalePolicy,
3566    ) -> Result<Vec<ReplacementIntent>, LifecycleError> {
3567        let mut replacements = Vec::new();
3568        self.pending_replacements
3569            .lock()
3570            .map_err(|_| LifecycleError::Journal)?
3571            .retain(|_, intent| {
3572                if intent.policy == policy.id {
3573                    replacements.push(*intent);
3574                    false
3575                } else {
3576                    true
3577                }
3578            });
3579        // Read under the set a reservation joins before it is journalled, so
3580        // every attempt read here that a bring-up may write to is known to be
3581        // one: it has no process yet and no registration, which this pass would
3582        // read as a runner gone, and what was read of it is out of date at once.
3583        let attempts = {
3584            let bringing_up = self
3585                .bringing_up
3586                .lock()
3587                .map_err(|_| LifecycleError::Journal)?;
3588            let mut attempts = self
3589                .ports
3590                .store
3591                .attempts_for_policy(policy.id)
3592                .map_err(|_| LifecycleError::Journal)?;
3593            attempts.retain(|attempt| !bringing_up.contains(&attempt.id));
3594            attempts
3595        };
3596        for attempt in attempts {
3597            authorize(self.host_id, policy, &attempt).map_err(|_| LifecycleError::Journal)?;
3598            if let ReconcileProgress::Replacement { attempt, operation } =
3599                self.reconcile_one(policy, attempt).await?
3600            {
3601                replacements.push(ReplacementIntent {
3602                    policy: policy.id,
3603                    previous_attempt: attempt,
3604                    operation,
3605                });
3606            }
3607        }
3608        Ok(replacements)
3609    }
3610
3611    async fn reconcile_one(
3612        &self,
3613        policy: &ScalePolicy,
3614        mut attempt: RunnerAttempt,
3615    ) -> Result<ReconcileProgress, LifecycleError> {
3616        if !attempt.execution().is_native() {
3617            return self.reconcile_isolated_one(policy, attempt).await;
3618        }
3619        if attempt.state() == AttemptState::Cleaned {
3620            // A child of Runner.Worker can outlive the runner process briefly.
3621            // If it recreates `_work` after `clean_attempt` removed the tree but
3622            // before the journal's final write, the journal truthfully reaches
3623            // `cleaned` and the directory appears again afterwards. Cleaned
3624            // attempts used to return here forever, making that late residue
3625            // invisible. The journalled ephemeral mode is the ownership proof:
3626            // retry the whole-tree removal, while a persistent slot deliberately
3627            // remains on disk with its retained `_work`.
3628            if matches!(attempt.workspace(), AttemptWorkspace::Ephemeral)
3629                && attempt.runtime_path().exists()
3630            {
3631                // The attempt is already terminal, has released its package
3632                // lease, and consumes no capacity. A late child may still hold
3633                // one of its recreated files open on Windows, so failure here
3634                // is residue to retry, not a reason to abort startup recovery
3635                // and take every unrelated repository offline.
3636                // Retried with backoff; the event is the one report, which the
3637                // event sink logs as a warning.
3638                let now = self.ports.clock.now();
3639                if !self.cleanup_retries().is_due(attempt.id, now) {
3640                    return Ok(ReconcileProgress::Reconciled);
3641                }
3642                match self.scrub_workspace(&attempt) {
3643                    Ok(()) => self.cleanup_retries().succeeded(attempt.id),
3644                    Err(LifecycleError::WorkspaceCleanupDeferred) => {
3645                        self.cleanup_retries().failed(attempt.id, now);
3646                        self.ports
3647                            .reconcile_events
3648                            .emit(LifecycleEvent::AttemptCleanFailed {
3649                                policy: attempt.policy_id,
3650                                attempt: attempt.id,
3651                                reason: "late_ephemeral_workspace_could_not_be_removed",
3652                            });
3653                    }
3654                    Err(error) => return Err(error),
3655                }
3656            }
3657            return Ok(ReconcileProgress::Reconciled);
3658        }
3659        if attempt.is_terminal() {
3660            self.clean_or_quarantine(&mut attempt)?;
3661            return Ok(ReconcileProgress::Reconciled);
3662        }
3663        let process_alive = self
3664            .ports
3665            .processes
3666            .inspect(&attempt)
3667            .map_err(LifecycleError::Failed)?
3668            == EnvironmentState::Running;
3669        let github = self
3670            .ports
3671            .github
3672            .observe(&policy.target, attempt.id, &self.cancel)
3673            .await;
3674
3675        // If the agent died after GitHub accepted the registration but before
3676        // the non-secret runner-id sidecar landed, inventory closes the gap.
3677        // Persist it before making any state decision so a second crash moves
3678        // the boundary forward rather than repeating it.
3679        if let Some(runner_id) = github.runner_id
3680            && read_runner_id(attempt.runtime_path()).is_none()
3681        {
3682            write_runner_id(attempt.runtime_path(), runner_id)?;
3683            self.ports
3684                .events
3685                .emit(AttemptEvent::RemoteIdentityRecovered {
3686                    attempt: attempt.id,
3687                    runner_id,
3688                });
3689        }
3690
3691        // The child identity is synced before `spawn` returns.  If the agent
3692        // crashed before the following `starting` journal write, recover that
3693        // exact PID and take the legal `jit_received -> starting` edge before
3694        // applying GitHub's authoritative idle/busy observation below.
3695        if attempt.state() == AttemptState::JitReceived
3696            && process_alive
3697            && let Some(pid) = self
3698                .ports
3699                .processes
3700                .recovered_pid(&attempt)
3701                .map_err(LifecycleError::Failed)?
3702        {
3703            attempt
3704                .started(pid, self.ports.clock.now())
3705                .map_err(|_| LifecycleError::Transition)?;
3706            self.record(&attempt)?;
3707        }
3708
3709        // What the runner's own diagnostics say about whether it ran a job.
3710        // Read before anything below can conclude the attempt, because cleanup
3711        // deletes `_diag` with the rest of the runtime directory.
3712        // A one-shot child owned by this invocation exited successfully. This
3713        // concludes the *runner attempt*, never the workflow outcome; GitHub
3714        // remains authoritative for that. GitHub's inventory is a sampled
3715        // witness: a job of a few seconds starts and ends between two passes,
3716        // and an assignment GitHub shows can be one the runner never took up.
3717        // The runner's own diagnostics decide, read before cleanup deletes
3718        // them, and GitHub's sample only decides when they say nothing. Not
3719        // while GitHub is unreachable: a registration still listed there could
3720        // not be removed, and nothing visits a concluded attempt again.
3721        if !process_alive
3722            && github.status != GithubRunnerObservation::Unreachable
3723            && matches!(
3724                attempt.state(),
3725                AttemptState::Starting | AttemptState::Idle | AttemptState::Busy
3726            )
3727            && self.ports.processes.completed_successfully(&attempt)
3728        {
3729            let state = attempt.state();
3730            let outcome = match (state, JobEvidence::of(attempt.runtime_path())) {
3731                // Without a runner id there is no edge to `busy`; the recovery
3732                // path below concludes the attempt without one.
3733                (AttemptState::Starting | AttemptState::Idle, JobEvidence::RanAJob) => {
3734                    known_runner_id(&attempt, github.runner_id)
3735                        .map(|runner_id| (Some(runner_id), AttemptOutcome::CompletedJob))
3736                }
3737                (AttemptState::Busy, JobEvidence::RanAJob) => {
3738                    Some((None, AttemptOutcome::CompletedJob))
3739                }
3740                (AttemptState::Busy, JobEvidence::NoJob) => {
3741                    Some((None, AttemptOutcome::ExitedIdleWithoutWork))
3742                }
3743                (AttemptState::Busy, JobEvidence::Unknown)
3744                    if github.status == GithubRunnerObservation::NotRegistered =>
3745                {
3746                    Some((None, AttemptOutcome::CompletedJob))
3747                }
3748                _ => None,
3749            };
3750            if let Some((walk_to_busy, outcome)) = outcome {
3751                // An ephemeral runner leaves GitHub's list a little after its
3752                // process exits; one still listed is removed here rather than
3753                // left for GitHub to time out. First, so the journal's last
3754                // intermediate state and the conclusion are written back to back:
3755                // an agent that dies between them recovers from the state it was
3756                // in before this pass.
3757                if matches!(github.status, GithubRunnerObservation::Registered { .. }) {
3758                    self.deregister_runner(policy, &attempt).await;
3759                }
3760                let now = self.ports.clock.now();
3761                match (walk_to_busy, &outcome) {
3762                    (Some(runner_id), _) => {
3763                        self.transition(&mut attempt, |a| a.assigned_job(runner_id, now))?;
3764                    }
3765                    (None, AttemptOutcome::ExitedIdleWithoutWork) => {
3766                        self.transition(&mut attempt, |a| a.assignment_not_taken(now))?;
3767                    }
3768                    (None, _) => {}
3769                }
3770                self.conclude(&mut attempt, outcome)?;
3771                self.clean_or_quarantine(&mut attempt)?;
3772                return Ok(ReconcileProgress::Reconciled);
3773            }
3774        }
3775
3776        // A live runner journalled `busy` that GitHub now shows waiting, and
3777        // whose diagnostics show no job: the assignment was never taken up.
3778        // Back to `idle`, where the idle timeout applies to it again; the
3779        // `busy` arm below would otherwise adopt it for as long as it runs.
3780        if process_alive
3781            && attempt.state() == AttemptState::Busy
3782            && github.status == (GithubRunnerObservation::Registered { busy: false })
3783            && JobEvidence::of(attempt.runtime_path()) == JobEvidence::NoJob
3784        {
3785            let now = self.ports.clock.now();
3786            self.transition(&mut attempt, |a| a.assignment_not_taken(now))?;
3787            return Ok(ReconcileProgress::Reconciled);
3788        }
3789
3790        // This durable mark is more authoritative than a later observation
3791        // which cannot distinguish an agent kill from a crash.
3792        if self.ports.processes.has_terminate_intent(&attempt) && !process_alive {
3793            self.deregister_runner(policy, &attempt).await;
3794            self.conclude(
3795                &mut attempt,
3796                AttemptOutcome::failed(FailureReason::TerminatedAfterRegistrationTimeout),
3797            )?;
3798            self.clean_or_quarantine(&mut attempt)?;
3799            return Ok(ReconcileProgress::Replacement {
3800                attempt: attempt.id,
3801                operation: "registration_timeout_replacement",
3802            });
3803        }
3804
3805        // A remote registration with no surviving process has lost its
3806        // one-shot JIT secret.  Walking it to `starting` would require inventing
3807        // a PID; retrying the same registration would require inventing the
3808        // secret.  Record the configuration as expired and let the bounded
3809        // replacement path request a fresh one only if demand remains.
3810        if matches!(
3811            attempt.state(),
3812            AttemptState::Allocated | AttemptState::JitReceived
3813        ) && !process_alive
3814            && matches!(github.status, GithubRunnerObservation::Registered { .. })
3815        {
3816            if attempt.state() == AttemptState::Allocated {
3817                attempt
3818                    .jit_received(self.ports.clock.now())
3819                    .map_err(|_| LifecycleError::Transition)?;
3820                self.record(&attempt)?;
3821            }
3822            self.deregister_runner(policy, &attempt).await;
3823            self.conclude(
3824                &mut attempt,
3825                AttemptOutcome::failed(FailureReason::JitExpired),
3826            )?;
3827            self.clean_or_quarantine(&mut attempt)?;
3828            return Ok(ReconcileProgress::Replacement {
3829                attempt: attempt.id,
3830                operation: "jit_expired_replacement",
3831            });
3832        }
3833
3834        match recovery_decision(
3835            &attempt,
3836            RecoveryObservation {
3837                process_alive,
3838                github: github.status,
3839            },
3840            self.timeouts,
3841            self.ports.clock.as_ref(),
3842        ) {
3843            RecoveryDecision::Nothing | RecoveryDecision::Wait => Ok(ReconcileProgress::Reconciled),
3844            RecoveryDecision::Defer => Ok(ReconcileProgress::Deferred),
3845            RecoveryDecision::Adopt => {
3846                self.ports.events.emit(AttemptEvent::Adopted {
3847                    attempt: attempt.id,
3848                });
3849                Ok(ReconcileProgress::Reconciled)
3850            }
3851            RecoveryDecision::Clean => {
3852                self.clean_or_quarantine(&mut attempt)?;
3853                Ok(ReconcileProgress::Reconciled)
3854            }
3855            RecoveryDecision::Observe(state) => {
3856                let runner_id = known_runner_id(&attempt, github.runner_id)
3857                    .ok_or(LifecycleError::Transition)?;
3858                match state {
3859                    AttemptState::JitReceived => attempt
3860                        .jit_received(self.ports.clock.now())
3861                        .map_err(|_| LifecycleError::Transition)?,
3862                    AttemptState::Starting => {
3863                        let pid = attempt.process_id().ok_or(LifecycleError::Transition)?;
3864                        attempt
3865                            .started(pid, self.ports.clock.now())
3866                            .map_err(|_| LifecycleError::Transition)?;
3867                    }
3868                    AttemptState::Idle => attempt
3869                        .registered_idle(runner_id, self.ports.clock.now())
3870                        .map_err(|_| LifecycleError::Transition)?,
3871                    AttemptState::Busy => attempt
3872                        .assigned_job(runner_id, self.ports.clock.now())
3873                        .map_err(|_| LifecycleError::Transition)?,
3874                    _ => return Err(LifecycleError::Transition),
3875                }
3876                self.record(&attempt)?;
3877                Ok(ReconcileProgress::Reconciled)
3878            }
3879            RecoveryDecision::Conclude(outcome) => {
3880                let replacement = replacement_operation(&outcome);
3881                // Only when GitHub still holds one. Every other conclusion here
3882                // was reached *because* the observation was `NotRegistered`, and
3883                // spending a DELETE to be told so again would put a request per
3884                // concluded attempt on a budget `rest.rs` prices to the request.
3885                if matches!(github.status, GithubRunnerObservation::Registered { .. }) {
3886                    self.deregister_runner(policy, &attempt).await;
3887                }
3888                self.conclude(&mut attempt, outcome)?;
3889                self.clean_or_quarantine(&mut attempt)?;
3890                Ok(
3891                    replacement.map_or(ReconcileProgress::Reconciled, |operation| {
3892                        ReconcileProgress::Replacement {
3893                            attempt: attempt.id,
3894                            operation,
3895                        }
3896                    }),
3897                )
3898            }
3899            RecoveryDecision::Terminate(payload) => {
3900                // The mark is synced first, and what proves the process died is
3901                // the `is_alive` re-read below -- not the outcome recorded after
3902                // it. Which outcome that is depends on why the termination was
3903                // ordered, and only the payload knows: a `starting` runner that
3904                // never registered is a failure this agent then stopped, while
3905                // an `idle` one past its timeout is flow 2.7's surplus exit and
3906                // no failure at all. Hardcoding the first reason here labelled
3907                // the second as a registration timeout and asked the allocator
3908                // for a replacement to boot.
3909                let idle_exit = payload.is_idle_exit();
3910                self.ports
3911                    .processes
3912                    .record_terminate_intent(&attempt)
3913                    .map_err(LifecycleError::Failed)?;
3914                self.ports.events.emit(AttemptEvent::TerminateIntent {
3915                    attempt: attempt.id,
3916                });
3917                if self
3918                    .ports
3919                    .processes
3920                    .stop(&attempt)
3921                    .map_err(LifecycleError::Failed)?
3922                    == ProviderStop::StillRunning
3923                {
3924                    return Ok(ReconcileProgress::Deferred);
3925                }
3926                if self
3927                    .ports
3928                    .processes
3929                    .inspect(&attempt)
3930                    .map_err(LifecycleError::Failed)?
3931                    == EnvironmentState::Running
3932                {
3933                    return Ok(ReconcileProgress::Deferred);
3934                }
3935                self.ports.events.emit(AttemptEvent::Terminated {
3936                    attempt: attempt.id,
3937                });
3938                // The registration-timeout path keeps deriving its own reason
3939                // rather than applying the payload: on the pass that reads the
3940                // journalled mark back the process is dead, and
3941                // `TerminatedAfterRegistrationTimeout` is the reason that stays
3942                // true of a dead process. See `RecoveryDecision::Terminate`.
3943                let outcome = if idle_exit {
3944                    AttemptOutcome::ExitedIdleWithoutWork
3945                } else {
3946                    AttemptOutcome::failed(FailureReason::TerminatedAfterRegistrationTimeout)
3947                };
3948                self.deregister_runner(policy, &attempt).await;
3949                self.conclude(&mut attempt, outcome)?;
3950                self.clean_or_quarantine(&mut attempt)?;
3951                // A surplus runner is not replaced. It was stopped precisely
3952                // because the work it was started for went elsewhere; asking the
3953                // allocator for another one rebuilds it every idle timeout.
3954                if idle_exit {
3955                    Ok(ReconcileProgress::Reconciled)
3956                } else {
3957                    Ok(ReconcileProgress::Replacement {
3958                        attempt: attempt.id,
3959                        operation: "registration_timeout_replacement",
3960                    })
3961                }
3962            }
3963        }
3964    }
3965
3966    async fn reconcile_isolated_one(
3967        &self,
3968        policy: &ScalePolicy,
3969        mut attempt: RunnerAttempt,
3970    ) -> Result<ReconcileProgress, LifecycleError> {
3971        if attempt.state() == AttemptState::Cleaned {
3972            return Ok(ReconcileProgress::Reconciled);
3973        }
3974        if attempt.is_terminal() {
3975            self.clean_or_quarantine(&mut attempt)?;
3976            return Ok(ReconcileProgress::Reconciled);
3977        }
3978        if matches!(
3979            attempt.state(),
3980            AttemptState::Allocated | AttemptState::Preparing | AttemptState::Prepared
3981        ) {
3982            // No JIT was requested yet. Restart can safely retire this attempt;
3983            // a matching owned resource is stopped by the durable cleanup path.
3984            self.conclude(
3985                &mut attempt,
3986                AttemptOutcome::failed(FailureReason::Other(
3987                    "isolated preparation interrupted".into(),
3988                )),
3989            )?;
3990            self.clean_or_quarantine(&mut attempt)?;
3991            return Ok(ReconcileProgress::Reconciled);
3992        }
3993        let Ok(provider_state) = self.ports.processes.recover(&attempt) else {
3994            return Ok(ReconcileProgress::Deferred);
3995        };
3996        if provider_state != EnvironmentState::Missing {
3997            let Ok(owned) = self.ports.processes.owns(&attempt) else {
3998                return Ok(ReconcileProgress::Deferred);
3999            };
4000            if !owned {
4001                self.ports
4002                    .events
4003                    .emit(AttemptEvent::QuarantinedOwnedResource {
4004                        attempt: attempt.id,
4005                    });
4006                tracing::warn!(attempt_id = %attempt.id, reason = "isolated_resource_ownership_mismatch", "provider resource quarantined for operator review");
4007                return Ok(ReconcileProgress::Deferred);
4008            }
4009        }
4010        let live = matches!(
4011            provider_state,
4012            EnvironmentState::Starting | EnvironmentState::Running
4013        );
4014        if attempt.state() == AttemptState::JitReceived && live {
4015            attempt
4016                .started_isolated(self.ports.clock.now())
4017                .map_err(|_| LifecycleError::Transition)?;
4018            self.record(&attempt)?;
4019        }
4020        let github = self
4021            .ports
4022            .github
4023            .observe(&policy.target, attempt.id, &self.cancel)
4024            .await;
4025        if let Some(runner_id) = github.runner_id
4026            && read_runner_id(attempt.runtime_path()).is_none()
4027        {
4028            write_runner_id(attempt.runtime_path(), runner_id)?;
4029        }
4030        // A successfully reaped one-shot listener is stronger evidence than a
4031        // missing registration. Recovered guests have no attached child and
4032        // still follow the conservative orphan path below.
4033        if attempt.state() == AttemptState::Busy
4034            && !live
4035            && github.status == GithubRunnerObservation::NotRegistered
4036            && self.ports.processes.completed_successfully(&attempt)
4037        {
4038            self.conclude(&mut attempt, AttemptOutcome::CompletedJob)?;
4039            self.clean_or_quarantine(&mut attempt)?;
4040            return Ok(ReconcileProgress::Reconciled);
4041        }
4042        match recovery_decision(
4043            &attempt,
4044            RecoveryObservation {
4045                process_alive: live,
4046                github: github.status,
4047            },
4048            self.timeouts,
4049            self.ports.clock.as_ref(),
4050        ) {
4051            RecoveryDecision::Nothing | RecoveryDecision::Wait => Ok(ReconcileProgress::Reconciled),
4052            RecoveryDecision::Defer => Ok(ReconcileProgress::Deferred),
4053            RecoveryDecision::Adopt => {
4054                self.ports.events.emit(AttemptEvent::Adopted {
4055                    attempt: attempt.id,
4056                });
4057                Ok(ReconcileProgress::Reconciled)
4058            }
4059            RecoveryDecision::Clean => {
4060                self.clean_or_quarantine(&mut attempt)?;
4061                Ok(ReconcileProgress::Reconciled)
4062            }
4063            RecoveryDecision::Observe(state) => {
4064                let runner_id = github
4065                    .runner_id
4066                    .or_else(|| read_runner_id(attempt.runtime_path()))
4067                    .ok_or(LifecycleError::Transition)?;
4068                match state {
4069                    AttemptState::Idle => attempt
4070                        .registered_idle(runner_id, self.ports.clock.now())
4071                        .map_err(|_| LifecycleError::Transition)?,
4072                    AttemptState::Busy => attempt
4073                        .assigned_job(runner_id, self.ports.clock.now())
4074                        .map_err(|_| LifecycleError::Transition)?,
4075                    _ => return Err(LifecycleError::Transition),
4076                }
4077                self.record(&attempt)?;
4078                Ok(ReconcileProgress::Reconciled)
4079            }
4080            RecoveryDecision::Conclude(outcome) | RecoveryDecision::Terminate(outcome) => {
4081                let replacement = replacement_operation(&outcome);
4082                if matches!(github.status, GithubRunnerObservation::Registered { .. }) {
4083                    self.deregister_runner(policy, &attempt).await;
4084                }
4085                self.conclude(&mut attempt, outcome)?;
4086                self.clean_or_quarantine(&mut attempt)?;
4087                Ok(
4088                    replacement.map_or(ReconcileProgress::Reconciled, |operation| {
4089                        ReconcileProgress::Replacement {
4090                            attempt: attempt.id,
4091                            operation,
4092                        }
4093                    }),
4094                )
4095            }
4096        }
4097    }
4098
4099    /// Takes one domain edge and journals it.
4100    fn transition(
4101        &self,
4102        attempt: &mut RunnerAttempt,
4103        edge: impl FnOnce(
4104            &mut RunnerAttempt,
4105        ) -> Result<(), runner_manager_domain::attempt::AttemptError>,
4106    ) -> Result<(), LifecycleError> {
4107        edge(attempt).map_err(|_| LifecycleError::Transition)?;
4108        self.record(attempt)
4109    }
4110
4111    fn record(&self, attempt: &RunnerAttempt) -> Result<(), LifecycleError> {
4112        self.ports
4113            .store
4114            .record_attempt(attempt)
4115            .map_err(|_| LifecycleError::Journal)?;
4116        self.ports.events.emit(AttemptEvent::State {
4117            attempt: attempt.id,
4118            state: attempt.state(),
4119        });
4120        Ok(())
4121    }
4122
4123    /// Remove the GitHub registration an attempt is about to leave behind.
4124    ///
4125    /// # Why this is not fallible, and does not block the conclusion
4126    ///
4127    /// GitHub retires an ephemeral runner itself once that runner *completes a
4128    /// job*, and for the ordinary path that is the whole story. The paths that
4129    /// reach here are the ones where it does not: a runner stopped before it
4130    /// was ever assigned work, a registration whose process died still holding
4131    /// it, a JIT configuration that expired. Nothing else deletes those, and
4132    /// before this existed nothing did — they accumulated in the target's
4133    /// runner settings, one row per attempt, for the life of the repository.
4134    ///
4135    /// It returns `()` rather than a `Result` because the alternative is worse
4136    /// in both directions. The attempt is over: its process is gone and its
4137    /// slot has to come back, so a failed delete may not abort the conclusion
4138    /// or the host leaks capacity every time GitHub is unreachable. And a
4139    /// registration that outlives this call is not lost — it is exactly the
4140    /// `Registered` + dead-process observation that
4141    /// [`AttemptOutcome::Orphaned`] already names, which a later pass can still
4142    /// see. So a failure is logged and stepped over, deliberately.
4143    async fn deregister_runner(&self, policy: &ScalePolicy, attempt: &RunnerAttempt) {
4144        let Some(runner_id) = attempt
4145            .github_runner_id()
4146            .or_else(|| read_runner_id(attempt.runtime_path()))
4147        else {
4148            return;
4149        };
4150        if self
4151            .ports
4152            .github
4153            .deregister(&policy.target, runner_id, &self.cancel)
4154            .await
4155        {
4156            self.ports.events.emit(AttemptEvent::Deregistered {
4157                attempt: attempt.id,
4158                runner_id,
4159            });
4160        } else {
4161            tracing::warn!(
4162                attempt = %attempt.id,
4163                runner_id,
4164                "the runner registration could not be removed from GitHub; it will show in the \
4165                 target's runner settings until GitHub retires it or a later pass removes it"
4166            );
4167        }
4168    }
4169
4170    fn conclude(
4171        &self,
4172        attempt: &mut RunnerAttempt,
4173        outcome: AttemptOutcome,
4174    ) -> Result<(), LifecycleError> {
4175        attempt
4176            .conclude(outcome.clone(), self.ports.clock.now())
4177            .map_err(|_| LifecycleError::Transition)?;
4178        self.record(attempt)?;
4179        self.ports.events.emit(AttemptEvent::Concluded {
4180            attempt: attempt.id,
4181            outcome: OutcomeKind::of(&outcome),
4182        });
4183        Ok(())
4184    }
4185
4186    /// Clean a concluded attempt, tolerating a quarantined persistent slot.
4187    ///
4188    /// The quarantine is reported and stepped over rather than raised, because
4189    /// raising it aborts the whole pass: on the startup path that leaves
4190    /// `recovery_complete` false and stops the host launching anything, which is
4191    /// the opposite of `04-security-recovery.md`'s "it does not count as active
4192    /// host capacity" and "recovery retries the same cleanup". The attempt keeps
4193    /// its state, so it keeps its slot lease and its directory, and the next
4194    /// pass — [`crate::reconcile::Reconciler`]'s terminal sweep on every poll,
4195    /// or the next startup — attempts exactly the same cleanup again.
4196    ///
4197    /// A persistent-slot quarantine and an ephemeral directory still held by a
4198    /// late process are tolerated. A journal failure or a package lease that
4199    /// cannot be released still propagates: those are not one workspace's
4200    /// problem.
4201    ///
4202    /// A tolerated failure is retried with per-attempt backoff: until it is
4203    /// due again the attempt is skipped silently rather than re-tried and
4204    /// re-reported on every pass.
4205    fn clean_or_quarantine(&self, attempt: &mut RunnerAttempt) -> Result<(), LifecycleError> {
4206        let now = self.ports.clock.now();
4207        if !self.cleanup_retries().is_due(attempt.id, now) {
4208            return Ok(());
4209        }
4210        let reason = match self.clean_attempt(attempt) {
4211            Err(LifecycleError::SlotQuarantined { class, .. }) => class,
4212            Err(LifecycleError::WorkspaceCleanupDeferred) => {
4213                failure_reason_kind(&FailureReason::WorkspaceCleanupDeferred)
4214            }
4215            Err(LifecycleError::EnvironmentCleanupDeferred) => {
4216                "isolated_environment_cleanup_deferred"
4217            }
4218            other => {
4219                if other.is_ok() {
4220                    self.cleanup_retries().succeeded(attempt.id);
4221                }
4222                return other;
4223            }
4224        };
4225        self.cleanup_retries().failed(attempt.id, now);
4226        self.ports
4227            .reconcile_events
4228            .emit(LifecycleEvent::AttemptCleanFailed {
4229                policy: attempt.policy_id,
4230                attempt: attempt.id,
4231                reason,
4232            });
4233        Ok(())
4234    }
4235
4236    fn cleanup_retries(&self) -> std::sync::MutexGuard<'_, CleanBackoff> {
4237        self.cleanup_retries
4238            .lock()
4239            .unwrap_or_else(std::sync::PoisonError::into_inner)
4240    }
4241
4242    fn clean_attempt(&self, attempt: &mut RunnerAttempt) -> Result<(), LifecycleError> {
4243        if !attempt.execution().is_native() {
4244            return self.clean_isolated_attempt(attempt);
4245        }
4246        match self
4247            .ports
4248            .processes
4249            .destroy(attempt)
4250            .map_err(LifecycleError::Failed)?
4251        {
4252            ProviderDestroy::Destroyed => {}
4253            ProviderDestroy::Deferred(reason) => {
4254                return Err(LifecycleError::Failed(FailureReason::Other(reason.into())));
4255            }
4256        }
4257        let outcome = attempt
4258            .outcome()
4259            .cloned()
4260            .ok_or(LifecycleError::Transition)?;
4261        self.preserve_diagnostics(attempt, &outcome)?;
4262        self.scrub_workspace(attempt)?;
4263        self.ports
4264            .packages
4265            .release(attempt.id)
4266            .map_err(LifecycleError::Failed)?;
4267        attempt
4268            .clean(self.ports.clock.now())
4269            .map_err(|_| LifecycleError::Transition)?;
4270        self.record(attempt)?;
4271        let kind = OutcomeKind::of(&outcome);
4272        self.ports.events.emit(AttemptEvent::Cleaned {
4273            attempt: attempt.id,
4274            outcome: kind,
4275        });
4276        self.ports
4277            .reconcile_events
4278            .emit(LifecycleEvent::AttemptCleaned {
4279                policy: attempt.policy_id,
4280                attempt: attempt.id,
4281                outcome: kind,
4282            });
4283        Ok(())
4284    }
4285
4286    fn clean_isolated_attempt(&self, attempt: &mut RunnerAttempt) -> Result<(), LifecycleError> {
4287        if attempt.state() != AttemptState::Destroying {
4288            attempt
4289                .begin_destroy(self.ports.clock.now())
4290                .map_err(|_| LifecycleError::Transition)?;
4291            self.record(attempt)?;
4292        }
4293        let provider = self.ports.processes.as_ref();
4294        let allocation_resources = provider.enumerate_owned(self.host_id);
4295        let matching_allocation = allocation_resources
4296            .iter()
4297            .filter(|identity| identity_same_allocation(self.host_id, attempt, identity))
4298            .collect::<Vec<_>>();
4299        if matching_allocation.len() > 1
4300            || matching_allocation
4301                .iter()
4302                .any(|identity| !identity_matches_intent(self.host_id, attempt, identity))
4303        {
4304            self.ports
4305                .events
4306                .emit(AttemptEvent::QuarantinedOwnedResource {
4307                    attempt: attempt.id,
4308                });
4309            tracing::warn!(attempt_id = %attempt.id, reason = "duplicate_or_mismatched_isolated_ownership", "provider resources quarantined for operator review");
4310            return Err(self.defer_environment_cleanup(attempt)?);
4311        }
4312        let has_identity = matches!(
4313            attempt.execution(),
4314            AttemptExecution::Isolated {
4315                environment_id: Some(_),
4316                ..
4317            }
4318        );
4319        if !has_identity
4320            && allocation_resources
4321                .iter()
4322                .any(|identity| identity_matches_intent(self.host_id, attempt, identity))
4323        {
4324            return Err(self.defer_environment_cleanup(attempt)?);
4325        }
4326        let current = if has_identity {
4327            provider.inspect(attempt)
4328        } else {
4329            // A prepare effect may have succeeded before its identity could be
4330            // journalled. Empty enumeration alone is not proof of absence.
4331            provider.recover(attempt)
4332        };
4333        let current = match current {
4334            Ok(state) => state,
4335            Err(_) => return Err(self.defer_environment_cleanup(attempt)?),
4336        };
4337        if !has_identity && current != EnvironmentState::Missing {
4338            return Err(self.defer_environment_cleanup(attempt)?);
4339        }
4340        if current != EnvironmentState::Missing {
4341            if !matches!(provider.owns(attempt), Ok(true)) {
4342                self.ports
4343                    .events
4344                    .emit(AttemptEvent::QuarantinedOwnedResource {
4345                        attempt: attempt.id,
4346                    });
4347                tracing::warn!(attempt_id = %attempt.id, reason = "isolated_resource_ownership_mismatch", "provider resource quarantined for operator review");
4348                return Err(self.defer_environment_cleanup(attempt)?);
4349            }
4350            if matches!(
4351                current,
4352                EnvironmentState::Starting | EnvironmentState::Running
4353            ) && !matches!(provider.stop(attempt), Ok(ProviderStop::Stopped))
4354            {
4355                return Err(self.defer_environment_cleanup(attempt)?);
4356            }
4357            if !matches!(provider.destroy(attempt), Ok(ProviderDestroy::Destroyed)) {
4358                return Err(self.defer_environment_cleanup(attempt)?);
4359            }
4360            if !matches!(provider.inspect(attempt), Ok(EnvironmentState::Missing)) {
4361                return Err(self.defer_environment_cleanup(attempt)?);
4362            }
4363        }
4364        if provider
4365            .enumerate_owned(self.host_id)
4366            .iter()
4367            .any(|identity| identity_same_allocation(self.host_id, attempt, identity))
4368        {
4369            return Err(self.defer_environment_cleanup(attempt)?);
4370        }
4371        let outcome = attempt
4372            .outcome()
4373            .cloned()
4374            .ok_or(LifecycleError::Transition)?;
4375        // Provider diagnostics are a closed vocabulary and bounded before they
4376        // can enter tracing, even if an adapter returns an unbounded vector.
4377        for diagnostic in provider.diagnostics(attempt).into_iter().take(32) {
4378            tracing::debug!(attempt_id = %attempt.id, provider_diagnostic = ?diagnostic, "provider lifecycle diagnostic");
4379        }
4380        self.preserve_diagnostics(attempt, &outcome)?;
4381        self.scrub_workspace(attempt)?;
4382        self.ports
4383            .packages
4384            .release(attempt.id)
4385            .map_err(LifecycleError::Failed)?;
4386        attempt
4387            .clean_isolated(self.ports.clock.now())
4388            .map_err(|_| LifecycleError::Transition)?;
4389        self.record(attempt)?;
4390        self.ports.events.emit(AttemptEvent::Cleaned {
4391            attempt: attempt.id,
4392            outcome: OutcomeKind::of(&outcome),
4393        });
4394        self.ports
4395            .reconcile_events
4396            .emit(LifecycleEvent::AttemptCleaned {
4397                policy: attempt.policy_id,
4398                attempt: attempt.id,
4399                outcome: OutcomeKind::of(&outcome),
4400            });
4401        Ok(())
4402    }
4403
4404    fn defer_environment_cleanup(
4405        &self,
4406        attempt: &mut RunnerAttempt,
4407    ) -> Result<LifecycleError, LifecycleError> {
4408        attempt
4409            .defer_cleanup(self.ports.clock.now())
4410            .map_err(|_| LifecycleError::Transition)?;
4411        self.record(attempt)?;
4412        Ok(LifecycleError::EnvironmentCleanupDeferred)
4413    }
4414
4415    /// Undo an attempt's placement by the algorithm its journalled workspace
4416    /// kind makes legal (`02-target-architecture.md`, "Cleanup and recovery").
4417    ///
4418    /// The dispatch is on the *journal*, never on what the directory looks like
4419    /// now. A slot whose `_work` was replaced by a junction is still scrubbed as
4420    /// a slot rather than removed whole, and a disposable directory that happens
4421    /// to contain a `_work` still goes whole rather than being spared: the
4422    /// workspace kind is immutable precisely so that the shape of a directory a
4423    /// workflow can write to cannot choose the algorithm applied to it.
4424    fn scrub_workspace(&self, attempt: &RunnerAttempt) -> Result<(), LifecycleError> {
4425        #[cfg(test)]
4426        {
4427            // The two contamination mutants `f1` drives the security gates with.
4428            // They sit in front of the dispatch rather than inside one arm so
4429            // that a skipped cleanup is equally observable in both modes.
4430            if matches!(
4431                std::env::var("RUNNER_MANAGER_TEST_MUTANT").as_deref(),
4432                Ok("skip_workspace_cleanup" | "reuse_job_workspace")
4433            ) {
4434                return Ok(());
4435            }
4436        }
4437        match attempt.workspace() {
4438            AttemptWorkspace::Ephemeral => match remove_runtime_tree(attempt.runtime_path()) {
4439                Ok(()) => Ok(()),
4440                Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
4441                Err(_) => Err(LifecycleError::WorkspaceCleanupDeferred),
4442            },
4443            AttemptWorkspace::PersistentSlot { slot } => self.scrub_persistent_slot(attempt, slot),
4444        }
4445    }
4446
4447    /// Retain exactly `_work` and prove everything else is gone.
4448    ///
4449    /// The seven ordered checks of `04-security-recovery.md`, "Safe path
4450    /// handling": the journalled root and slot, a surviving policy's agreement,
4451    /// lexical and canonical containment, a literal enumeration, the one-entry
4452    /// allowlist, and a verification pass before the caller releases the package
4453    /// lease and marks the attempt cleaned. Any of them may refuse, and a
4454    /// refusal removes nothing after it.
4455    ///
4456    /// A slot directory that is already gone is not a refusal. There is nothing
4457    /// to scrub and nothing to prove absent, which is the same tolerance the
4458    /// disposable arm has always had for a runtime that vanished under it.
4459    fn scrub_persistent_slot(
4460        &self,
4461        attempt: &RunnerAttempt,
4462        slot: NonZeroU16,
4463    ) -> Result<(), LifecycleError> {
4464        // The policy is read from the journal, never inferred from the
4465        // directory tree, and its absence is legal: `Reconciler` cleans every
4466        // concluded attempt whether or not its policy still exists, and this
4467        // one's root is already journalled on the attempt itself.
4468        let configured = self
4469            .ports
4470            .store
4471            .policy(attempt.policy_id)
4472            .map_err(|_| LifecycleError::Journal)?
4473            .and_then(|policy| match policy.workspace_policy() {
4474                WorkspacePolicy::Persistent { root } => Some(root.clone()),
4475                WorkspacePolicy::Ephemeral => None,
4476            });
4477        let runtime = attempt.runtime_path();
4478        self.quarantine_on_refusal(
4479            attempt,
4480            verify_journalled_slot(runtime, slot, configured.as_ref())
4481                .and_then(|()| slot_is_present(runtime))
4482                .and_then(|present| {
4483                    if present {
4484                        scrub_slot_entries(runtime).and_then(|()| verify_slot_scrubbed(runtime))
4485                    } else {
4486                        Ok(())
4487                    }
4488                }),
4489        )
4490    }
4491
4492    /// Turn a slot refusal into the error that keeps the attempt uncleaned.
4493    ///
4494    /// The warning is emitted here, at the one place a quarantine is minted, so
4495    /// that both routes out of cleanup carry it: the reconciler's terminal sweep,
4496    /// which receives the error through the launcher port, and
4497    /// [`Self::clean_or_quarantine`], which swallows it to keep the pass alive.
4498    /// Everything logged is either a path this product configured or a constant
4499    /// from this module — see [`SlotQuarantine`] for why that matters.
4500    fn quarantine_on_refusal(
4501        &self,
4502        attempt: &RunnerAttempt,
4503        outcome: Result<(), SlotQuarantine>,
4504    ) -> Result<(), LifecycleError> {
4505        let Err(quarantine) = outcome else {
4506            return Ok(());
4507        };
4508        let detail = quarantine.to_string();
4509        tracing::warn!(
4510            attempt = %attempt.id,
4511            policy = %attempt.policy_id,
4512            slot = attempt.workspace().slot_number(),
4513            refusal = quarantine.refusal.class(),
4514            "{detail}"
4515        );
4516        Err(LifecycleError::SlotQuarantined {
4517            class: quarantine.refusal.class(),
4518            detail,
4519        })
4520    }
4521
4522    fn preserve_diagnostics(
4523        &self,
4524        attempt: &RunnerAttempt,
4525        outcome: &AttemptOutcome,
4526    ) -> Result<(), LifecycleError> {
4527        fs::create_dir_all(&self.diagnostics_root).map_err(|_| {
4528            LifecycleError::Failed(FailureReason::Other(
4529                "diagnostics directory could not be created".into(),
4530            ))
4531        })?;
4532        // Intentionally constructed from typed local facts, not runner output.
4533        // Raw child output can contain workflow secrets and is never copied.
4534        let reason_kind = match outcome {
4535            AttemptOutcome::Failed { reason } => failure_reason_kind(reason),
4536            _ => "none",
4537        };
4538        let diagnostic = format!(
4539            "attempt_id={}\npolicy_id={}\noutcome={}\nreason={}\n",
4540            attempt.id,
4541            attempt.policy_id,
4542            OutcomeKind::of(outcome).as_str(),
4543            reason_kind,
4544        );
4545        fs::write(
4546            self.diagnostics_root.join(format!("{}.log", attempt.id)),
4547            diagnostic,
4548        )
4549        .map_err(|_| {
4550            LifecycleError::Failed(FailureReason::Other(
4551                "redacted diagnostics could not be preserved".into(),
4552            ))
4553        })
4554    }
4555
4556    async fn materialize_with_retry(
4557        &self,
4558        policy: &ScalePolicy,
4559        attempt: &RunnerAttempt,
4560        timings: &mut PackageTimings,
4561    ) -> Result<RunnerVersion, FailureReason> {
4562        let mut issued = 0_u32;
4563        loop {
4564            issued = issued.saturating_add(1);
4565            match self.ports.packages.materialize(attempt, timings).await {
4566                Ok(version) => return Ok(version),
4567                Err(reason)
4568                    if package_failure_is_terminal(&reason)
4569                        || issued >= self.retry.max_attempts.max(1) =>
4570                {
4571                    return Err(reason);
4572                }
4573                Err(reason) => {
4574                    if !self.ports.demand.persists(policy.id).await {
4575                        return Err(reason);
4576                    }
4577                    let delay = self.retry.delay(issued);
4578                    self.ports.events.emit(AttemptEvent::Retry {
4579                        attempt: attempt.id,
4580                        operation: "package_materialization",
4581                        delay,
4582                    });
4583                    self.ports.delay.wait(delay).await;
4584                    if !self.ports.demand.persists(policy.id).await {
4585                        return Err(reason);
4586                    }
4587                }
4588            }
4589        }
4590    }
4591
4592    async fn register_with_retry(
4593        &self,
4594        policy: &ScalePolicy,
4595        attempt: AttemptId,
4596        request: &JitRunnerRequest,
4597    ) -> Result<JitRegistration, LifecycleError> {
4598        let mut issued = 0_u32;
4599        loop {
4600            issued = issued.saturating_add(1);
4601            match self
4602                .ports
4603                .github
4604                .register(&policy.target, request, &self.cancel)
4605                .await
4606            {
4607                Ok(registration) => return Ok(registration),
4608                Err(error) if error.terminal => {
4609                    return Err(LifecycleError::Failed(error.reason));
4610                }
4611                Err(error) => {
4612                    if issued >= self.retry.max_attempts.max(1)
4613                        || !self.ports.demand.persists(policy.id).await
4614                    {
4615                        return Err(LifecycleError::Failed(error.reason));
4616                    }
4617                    let delay = error
4618                        .retry_after
4619                        .unwrap_or_else(|| self.retry.delay(issued));
4620                    self.ports.events.emit(AttemptEvent::Retry {
4621                        attempt,
4622                        operation: "jit_request",
4623                        delay,
4624                    });
4625                    self.ports.delay.wait(delay).await;
4626                    if !self.ports.demand.persists(policy.id).await {
4627                        return Err(LifecycleError::Failed(error.reason));
4628                    }
4629                }
4630            }
4631        }
4632    }
4633
4634    /// Where one attempt's files go, decided while the host allocation lock is
4635    /// held and before anything external happens.
4636    ///
4637    /// The branch is on the *repository's configured* workspace policy, so an
4638    /// organization policy and an ephemeral repository never reach slot
4639    /// selection at all: a persistent policy is unrepresentable for an
4640    /// organization target (D7, refused by `WorkspacePolicy::permitted_for` in
4641    /// both the constructor and the loader), and an ephemeral repository takes
4642    /// the disposable arm that existed before slots did.
4643    fn allocate_workspace(
4644        &self,
4645        policy: &ScalePolicy,
4646        id: AttemptId,
4647    ) -> Result<Placement, LifecycleError> {
4648        let placement = match policy.workspace_policy() {
4649            // Precedence (`02-target-architecture.md`): the repository's
4650            // persistent root is selected *before* the host root, which is why
4651            // this arm is first and why it never makes resolving the host
4652            // default a precondition of its own success.
4653            WorkspacePolicy::Persistent { root } => self.allocate_persistent_slot(policy, root),
4654            WorkspacePolicy::Ephemeral => self.allocate_disposable(policy, id),
4655        };
4656        // Here rather than inside the two arms, so that every path a root can
4657        // accept clears the record and none can be forgotten.
4658        if placement.is_ok() {
4659            self.root_accepted(policy.id);
4660        }
4661        placement
4662    }
4663
4664    /// `Host.runner_root_override`, read from the journal.
4665    ///
4666    /// Separated from [`Self::effective_host_root`] so that the two failures it
4667    /// folds together stay apart: an unreadable or missing host row is a journal
4668    /// problem and is always fatal, while an unresolvable *platform default* is
4669    /// only fatal to a placement that actually needs the host root.
4670    fn configured_host_root(&self) -> Result<Option<LocalAbsolutePath>, LifecycleError> {
4671        let host = self
4672            .ports
4673            .store
4674            .host(self.host_id)
4675            .map_err(|_| LifecycleError::Journal)?
4676            .ok_or_else(|| LifecycleError::Failed(FailureReason::Other("host not found".into())))?;
4677        Ok(host.runner_root_override.clone())
4678    }
4679
4680    /// `Host.runner_root_override`, or the platform default when that resolves;
4681    /// `None` when neither does. Unlike [`Self::effective_host_root`] it records
4682    /// no refusal, for callers to whom an unresolvable root is not a failure.
4683    fn resolvable_host_root(&self) -> Result<Option<LocalAbsolutePath>, LifecycleError> {
4684        Ok(self
4685            .configured_host_root()?
4686            .or_else(|| default_runner_root(&self.app_paths).ok()))
4687    }
4688
4689    /// `Host.runner_root_override`, or the platform default standing in for it.
4690    ///
4691    /// Takes the policy because an unresolvable default is a refusal like any
4692    /// other, and the record it leaves is that policy's.
4693    fn effective_host_root(
4694        &self,
4695        policy: &ScalePolicy,
4696    ) -> Result<LocalAbsolutePath, LifecycleError> {
4697        match self.configured_host_root()? {
4698            Some(configured) => Ok(configured),
4699            None => default_runner_root(&self.app_paths).map_err(|error| {
4700                // No root resolved, so there is no path to name but the one the
4701                // platform would have produced.
4702                self.root_refused(policy.id, "the platform default runner root", error)
4703            }),
4704        }
4705    }
4706
4707    /// Turns a runner-root refusal into a failure, and leaves the sentence
4708    /// somewhere an operator can read it.
4709    ///
4710    /// Per policy, because the policies on a host do not share a fate: one with
4711    /// its own persistent root places runners while another on a withheld
4712    /// volume places none, and a host-wide record would have the first clear
4713    /// the second's on the same pass.
4714    ///
4715    /// The recording is best-effort and its failure is deliberately swallowed:
4716    /// this runs on the path that is already failing, and a host that cannot
4717    /// write a diagnostic file must still report the refusal it came to report.
4718    /// `service status` says so itself when the file is unreadable.
4719    fn root_refused(&self, policy: PolicyId, root: &str, error: RunnerRootError) -> LifecycleError {
4720        let _ = runner_manager_platform::service::record_runner_root_refusal(
4721            &self.app_paths,
4722            &policy.to_string(),
4723            self.ports.clock.now(),
4724            error.kind(),
4725            root,
4726            &error.to_string(),
4727        );
4728        root_failure(error)
4729    }
4730
4731    /// Clears that policy's record, because its root accepted a placement.
4732    ///
4733    /// Called on every successful placement rather than only after a failure:
4734    /// the daemon that recovers is often not the process that failed -- a
4735    /// self-update restarts it -- so "clear it if we wrote it" would leave a
4736    /// stale note on `service status` for as long as the host ran.
4737    fn root_accepted(&self, policy: PolicyId) {
4738        let _ = runner_manager_platform::service::clear_runner_root_refusal(
4739            &self.app_paths,
4740            &policy.to_string(),
4741        );
4742    }
4743
4744    /// D3's disposable placement: a unique child of the effective host root,
4745    /// removed whole on cleanup. `c1`'s behaviour, moved behind the branch.
4746    fn allocate_disposable(
4747        &self,
4748        policy: &ScalePolicy,
4749        id: AttemptId,
4750    ) -> Result<Placement, LifecycleError> {
4751        let effective_root = self.effective_host_root(policy)?;
4752        RootPreflight::new(&self.app_paths)
4753            .check(&RootOwner::Host, &effective_root)
4754            .map_err(|error| self.root_refused(policy.id, effective_root.as_str(), error))?;
4755        let runtime = effective_root.as_path().join({
4756            #[cfg(test)]
4757            {
4758                if std::env::var("RUNNER_MANAGER_TEST_MUTANT").as_deref()
4759                    == Ok("reuse_job_workspace")
4760                {
4761                    "mutant-shared-workspace".to_owned()
4762                } else {
4763                    workspace_name(id)
4764                }
4765            }
4766            #[cfg(not(test))]
4767            {
4768                workspace_name(id)
4769            }
4770        });
4771        fs::create_dir_all(&runtime)
4772            .map_err(|_| LifecycleError::Failed(FailureReason::ProcessStartFailed))?;
4773        Ok(Placement {
4774            runtime,
4775            workspace: AttemptWorkspace::Ephemeral,
4776        })
4777    }
4778
4779    /// D4/D5's persistent placement: the lowest free `sN` under the repository's
4780    /// configured root.
4781    ///
4782    /// Steps 1 to 6 of `02-target-architecture.md`, "Slot allocation", in order;
4783    /// step 7 is the journal write [`Self::record_allocation`] owns. All of them
4784    /// run under the host allocation lock, because [`Self::reserve_attempt`] is
4785    /// reachable only through a `LaunchRequest` and that carries the guard.
4786    ///
4787    /// **The filesystem is never consulted to decide which slots are taken**
4788    /// (invariant 6). The leases come from the journal; the directory is
4789    /// inspected only to decide whether *this* slot is safe to reuse.
4790    fn allocate_persistent_slot(
4791        &self,
4792        policy: &ScalePolicy,
4793        root: &LocalAbsolutePath,
4794    ) -> Result<Placement, LifecycleError> {
4795        // 1-4. The lowest positive slot no uncleaned attempt holds, refused
4796        // above the policy ceiling.
4797        let ceiling = policy.max_capacity().ok_or_else(|| {
4798            LifecycleError::Failed(FailureReason::Other(
4799                "a persistent workspace needs the policy's max_capacity to bound its slots"
4800                    .to_string(),
4801            ))
4802        })?;
4803        let leases = self
4804            .ports
4805            .store
4806            .slot_leases_for_policy(policy.id)
4807            .map_err(|_| LifecycleError::Journal)?;
4808        let slot = lowest_free_slot(&leases, ceiling).ok_or_else(|| {
4809            LifecycleError::Failed(FailureReason::Other(format!(
4810                "every persistent slot s1 to s{ceiling} for {} is leased by an attempt that has \
4811                 not been cleaned, so no slot is free; raise the repository's max capacity, or \
4812                 finish cleaning a concluded attempt",
4813                policy.target
4814            )))
4815        })?;
4816        let workspace = AttemptWorkspace::persistent_slot(slot);
4817        let name = workspace
4818            .slot_directory_name()
4819            .expect("a persistent allocation names its slot directory");
4820
4821        // The operational preflight, for the reasons the host root gets one: a
4822        // root that is remote, unwritable, or overlapping application data has
4823        // to fail before a directory is created rather than after. The host
4824        // root is registered only as something *not* to overlap; a host default
4825        // that cannot be resolved is a host-root problem and does not block a
4826        // repository that configured a root of its own.
4827        //
4828        // Only *that* failure is tolerated. An unreadable host row is a journal
4829        // failure and propagates, because silently continuing would drop the
4830        // overlap check entirely and accept a repository root that sits inside
4831        // the host root — the pair `RootPreflight` exists to refuse.
4832        let host_root = self.resolvable_host_root()?;
4833        let mut preflight = RootPreflight::new(&self.app_paths);
4834        if let Some(host_root) = host_root {
4835            preflight = preflight.against(RootOwner::Host, host_root);
4836        }
4837        let checked = preflight
4838            .check(&RootOwner::Repository(policy.target.to_string()), root)
4839            .map_err(|error| self.root_refused(policy.id, root.as_str(), error))?;
4840        if let Some(leaf) = checked.leaf_to_create() {
4841            fs::create_dir(leaf).map_err(|source| {
4842                LifecycleError::Failed(FailureReason::Other(format!(
4843                    "the persistent workspace root {} could not be created: {source}",
4844                    leaf.display()
4845                )))
4846            })?;
4847        }
4848
4849        // 5-6. `<root>/sN`, contained lexically by construction, then created or
4850        // validated, then contained canonically now that it resolves, and only
4851        // then accepted for reuse.
4852        let slot_path = runner_root::derive_child(root, &name)
4853            .map_err(|error| self.root_refused(policy.id, root.as_str(), error))?;
4854        create_or_validate_slot(slot_path.as_path())?;
4855        runner_root::verify_containment(root, &slot_path)
4856            .map_err(|error| self.root_refused(policy.id, root.as_str(), error))?;
4857        accept_reusable_slot(slot_path.as_path())?;
4858        Ok(Placement {
4859            runtime: slot_path.as_path().to_path_buf(),
4860            workspace,
4861        })
4862    }
4863
4864    /// The first journal write of an attempt, where a duplicate slot lease is
4865    /// still possible and has to be reported as itself.
4866    ///
4867    /// [`Self::record`] flattens every store failure into
4868    /// [`LifecycleError::Journal`], which is right for a state transition and
4869    /// wrong here: the partial unique index
4870    /// `one_uncleaned_persistent_attempt_per_slot` is the final race fence
4871    /// (`04-security-recovery.md`, "two attempts use one slot concurrently"),
4872    /// and an operator who reaches it needs to read that rather than "attempt
4873    /// journal operation failed". Nothing was written, so the caller returns
4874    /// without concluding an attempt that is not in the journal.
4875    fn record_allocation(&self, attempt: &RunnerAttempt) -> Result<(), LifecycleError> {
4876        match self.ports.store.record_attempt(attempt) {
4877            Ok(()) => {
4878                self.ports.events.emit(AttemptEvent::State {
4879                    attempt: attempt.id,
4880                    state: attempt.state(),
4881                });
4882                Ok(())
4883            }
4884            Err(error @ StoreError::SlotAlreadyLeased { .. }) => Err(LifecycleError::Failed(
4885                FailureReason::Other(error.to_string()),
4886            )),
4887            Err(_) => Err(LifecycleError::Journal),
4888        }
4889    }
4890
4891    /// Under the allocation lock: what no other launch may decide at the same
4892    /// time. The rest is [`Self::finish_attempt`], outside it.
4893    fn reserve_attempt(
4894        &self,
4895        policy: &ScalePolicy,
4896        allocation_guard: &AllocationGuard,
4897    ) -> Result<RunnerAttempt, LifecycleError> {
4898        // What a prune leaves costs only disk, and a tree it cannot remove is one
4899        // the launch's install may yet repair: not a reason to refuse the launch.
4900        if let Err(error) = self.prune_under_allocation_lock(allocation_guard) {
4901            tracing::warn!(
4902                event = "package_prune_failed",
4903                reason = failure_reason_kind(&error.reason()),
4904            );
4905        }
4906        self.allocate_attempt(policy)
4907    }
4908
4909    async fn finish_attempt(
4910        &self,
4911        policy: &ScalePolicy,
4912        attempt: RunnerAttempt,
4913        waits: ReservationWaits,
4914    ) -> Result<RunnerAttempt, LifecycleError> {
4915        let id = attempt.id;
4916        let _bringing_up = BroughtUpOnDrop {
4917            attempts: &self.bringing_up,
4918            id,
4919        };
4920        let mut stopwatch = LaunchStopwatch::new(waits);
4921        let result = self.launch_steps(policy, attempt, &mut stopwatch).await;
4922        stopwatch.finish();
4923        self.ports.events.emit(AttemptEvent::Launched {
4924            attempt: id,
4925            started: result.is_ok(),
4926            timings: stopwatch.timings,
4927        });
4928        result
4929    }
4930
4931    /// Place and journal a new attempt: everything before its first external
4932    /// effect, and so before there is anything to time.
4933    fn allocate_attempt(&self, policy: &ScalePolicy) -> Result<RunnerAttempt, LifecycleError> {
4934        if !*self
4935            .recovery_complete
4936            .lock()
4937            .map_err(|_| LifecycleError::Journal)?
4938        {
4939            return Err(LifecycleError::RecoveryIncomplete);
4940        }
4941        let capability = self.ports.processes.probe(policy);
4942        if capability != ProviderCapability::Ready {
4943            return Err(LifecycleError::Failed(capability.refusal()));
4944        }
4945        let resolved = self.ports.processes.resolve(policy).map_err(|reason| {
4946            LifecycleError::Failed(if policy.execution_policy().is_native() {
4947                reason
4948            } else {
4949                safe_isolation_reason(reason, IsolationProviderFailure::RuntimeOperationFailed)
4950            })
4951        })?;
4952        if policy.execution_policy().is_native() != resolved.is_none() {
4953            return Err(LifecycleError::Failed(FailureReason::Other(
4954                "provider resolution mismatch".into(),
4955            )));
4956        }
4957        if let Some(resolution) = &resolved {
4958            if resolution.provider_kind == Backend::Auto {
4959                return Err(LifecycleError::Failed(FailureReason::Other(
4960                    "provider resolution mismatch".into(),
4961                )));
4962            }
4963            AttemptExecution::Isolated {
4964                provider_kind: resolution.provider_kind,
4965                environment_id: None,
4966                resolved_image: resolution.image.clone(),
4967                generation: "generation-check".into(),
4968            }
4969            .validate()
4970            .map_err(|_| {
4971                LifecycleError::Failed(FailureReason::Other(
4972                    "resolved image is incompatible".into(),
4973                ))
4974            })?;
4975        }
4976        policy
4977            .routing_labels()
4978            .ok_or(LifecycleError::Failed(FailureReason::JitRequestFailed))?;
4979        let id = AttemptId::new_random();
4980        let placement = self.allocate_workspace(policy, id)?;
4981        let mut attempt = RunnerAttempt::allocate_in(
4982            id,
4983            policy.id,
4984            placement.runtime,
4985            placement.workspace,
4986            self.ports.clock.now(),
4987        );
4988        if let Some(resolution) = &resolved {
4989            attempt
4990                .allocate_execution(AttemptExecution::Isolated {
4991                    provider_kind: resolution.provider_kind,
4992                    environment_id: None,
4993                    resolved_image: resolution.image.clone(),
4994                    generation: uuid::Uuid::new_v4().to_string(),
4995                })
4996                .map_err(|_| LifecycleError::Transition)?;
4997        }
4998        // This is deliberately the first effect after directory allocation, and
4999        // for a persistent attempt it is also what makes the slot lease durable
5000        // before any package or GitHub effect
5001        // (`02-target-architecture.md`, "Slot allocation", step 7).
5002        self.bringing_up
5003            .lock()
5004            .map_err(|_| LifecycleError::Journal)?
5005            .insert(id);
5006        if let Err(error) = self.record_allocation(&attempt) {
5007            self.bringing_up
5008                .lock()
5009                .map_err(|_| LifecycleError::Journal)?
5010                .remove(&id);
5011            return Err(error);
5012        }
5013        Ok(attempt)
5014    }
5015
5016    async fn launch_steps(
5017        &self,
5018        policy: &ScalePolicy,
5019        mut attempt: RunnerAttempt,
5020        stopwatch: &mut LaunchStopwatch,
5021    ) -> Result<RunnerAttempt, LifecycleError> {
5022        let id = attempt.id;
5023        let isolated = !attempt.execution().is_native();
5024        let Some(labels) = policy.routing_labels() else {
5025            return self.fail_launch(&mut attempt, FailureReason::JitRequestFailed);
5026        };
5027        stopwatch.begin(LaunchStep::Turn);
5028        // Never closed; held through the preparation below.
5029        let turn = self.turns.acquire().await.ok();
5030        stopwatch.begin(LaunchStep::Materialize);
5031        let package_timings = &mut stopwatch.timings.package;
5032        let version = match self
5033            .materialize_with_retry(policy, &attempt, package_timings)
5034            .await
5035        {
5036            Ok(version) => version,
5037            Err(reason) => return self.fail_launch(&mut attempt, reason),
5038        };
5039        self.versions
5040            .lock()
5041            .map_err(|_| LifecycleError::Journal)?
5042            .insert(id, version);
5043
5044        if isolated {
5045            attempt
5046                .begin_prepare(self.ports.clock.now())
5047                .map_err(|_| LifecycleError::Transition)?;
5048            self.record(&attempt)?;
5049        }
5050        stopwatch.begin(LaunchStep::Prepare);
5051        // Off the async thread: a provider may pull an image, and every target
5052        // of this host shares that thread.
5053        let preparing = {
5054            let (processes, attempt, policy) = (
5055                Arc::clone(&self.ports.processes),
5056                attempt.clone(),
5057                policy.clone(),
5058            );
5059            tokio::task::spawn_blocking(move || processes.prepare(&attempt, &policy)).await
5060        };
5061        drop(turn);
5062        let prepared = match preparing.unwrap_or(Err(FailureReason::ProcessStartFailed)) {
5063            Ok(prepared) => prepared,
5064            Err(reason) => {
5065                let safe_reason = if isolated {
5066                    safe_isolation_reason(reason, IsolationProviderFailure::RuntimeOperationFailed)
5067                } else {
5068                    reason
5069                };
5070                return self.fail_launch(&mut attempt, safe_reason);
5071            }
5072        };
5073        if isolated {
5074            let Some(identity) = prepared.identity() else {
5075                return Err(LifecycleError::Failed(FailureReason::IsolationProvider(
5076                    IsolationProviderFailure::OwnershipMismatch,
5077                )));
5078            };
5079            if !identity_matches_intent(self.host_id, &attempt, identity) {
5080                return Err(LifecycleError::Failed(FailureReason::IsolationProvider(
5081                    IsolationProviderFailure::OwnershipMismatch,
5082                )));
5083            }
5084            let EnvironmentIdentity::Isolated { environment_id, .. } = identity else {
5085                unreachable!()
5086            };
5087            attempt
5088                .prepared_environment(environment_id.clone())
5089                .map_err(|_| LifecycleError::Transition)?;
5090            attempt
5091                .mark_prepared(self.ports.clock.now())
5092                .map_err(|_| LifecycleError::Transition)?;
5093            self.record(&attempt)?;
5094        }
5095
5096        stopwatch.begin(LaunchStep::Register);
5097        let jit_request =
5098            JitRunnerRequest::for_policy(runner_name(id), self.runner_group_id, labels);
5099        let registration = match self.register_with_retry(policy, id, &jit_request).await {
5100            Ok(registration) => registration,
5101            Err(error) => return self.fail_launch(&mut attempt, error.reason()),
5102        };
5103        stopwatch.begin(LaunchStep::Spawn);
5104        let runner_id = registration.runner().id;
5105        write_runner_id(attempt.runtime_path(), runner_id)?;
5106        attempt
5107            .jit_received(self.ports.clock.now())
5108            .map_err(|_| LifecycleError::Transition)?;
5109        self.record(&attempt)?;
5110        let config = registration.into_config();
5111        if isolated {
5112            let started = self
5113                .ports
5114                .processes
5115                .start(prepared, &attempt, OneTimeJitHandoff::new(&config))
5116                .map_err(|failure| {
5117                    LifecycleError::Failed(safe_isolation_reason(
5118                        failure.reason,
5119                        IsolationProviderFailure::RuntimeOperationFailed,
5120                    ))
5121                })?;
5122            if !identity_matches_intent(self.host_id, &attempt, &started) {
5123                return Err(LifecycleError::Failed(FailureReason::IsolationProvider(
5124                    IsolationProviderFailure::OwnershipMismatch,
5125                )));
5126            }
5127            attempt
5128                .started_isolated(self.ports.clock.now())
5129                .map_err(|_| LifecycleError::Transition)?;
5130            self.record(&attempt)?;
5131            return Ok(attempt);
5132        }
5133        let mut issued = 0_u32;
5134        let pid = loop {
5135            issued = issued.saturating_add(1);
5136            match self.ports.processes.start(
5137                prepared.clone(),
5138                &attempt,
5139                OneTimeJitHandoff::new(&config),
5140            ) {
5141                Ok(EnvironmentIdentity::NativeProcess(pid)) => break pid,
5142                Ok(EnvironmentIdentity::Isolated { .. }) => {
5143                    // No isolated launch sequence is wired in this task. Keep
5144                    // the nonterminal JIT journal as a capacity fence rather
5145                    // than treating an unexpected resource as a native PID.
5146                    return Err(LifecycleError::Failed(FailureReason::Other(
5147                        "unexpected isolated environment identity".into(),
5148                    )));
5149                }
5150                Err(error) => {
5151                    if let Some(pid) = error.live_pid {
5152                        attempt
5153                            .started(pid, self.ports.clock.now())
5154                            .map_err(|_| LifecycleError::Transition)?;
5155                        self.record(&attempt)?;
5156                        return Err(LifecycleError::Failed(error.reason));
5157                    }
5158                    if !error.retryable
5159                        || issued >= self.retry.max_attempts.max(1)
5160                        || !self.ports.demand.persists(policy.id).await
5161                    {
5162                        return self.fail_launch(&mut attempt, error.reason);
5163                    }
5164                    let delay = self.retry.delay(issued);
5165                    self.ports.events.emit(AttemptEvent::Retry {
5166                        attempt: attempt.id,
5167                        operation: "process_start",
5168                        delay,
5169                    });
5170                    self.ports.delay.wait(delay).await;
5171                    if !self.ports.demand.persists(policy.id).await {
5172                        return self.fail_launch(&mut attempt, error.reason);
5173                    }
5174                }
5175            }
5176        };
5177        attempt
5178            .started(pid, self.ports.clock.now())
5179            .map_err(|_| LifecycleError::Transition)?;
5180        self.record(&attempt)?;
5181        Ok(attempt)
5182    }
5183
5184    fn fail_launch<T>(
5185        &self,
5186        attempt: &mut RunnerAttempt,
5187        reason: FailureReason,
5188    ) -> Result<T, LifecycleError> {
5189        self.conclude(attempt, AttemptOutcome::failed(reason.clone()))?;
5190        Err(LifecycleError::Failed(reason))
5191    }
5192
5193    /// e2's prune guard is invoked only with e1's allocation guard borrowed.
5194    /// The otherwise-unused argument is a compile-time witness of the ordering.
5195    fn prune_under_allocation_lock(&self, guard: &AllocationGuard) -> Result<(), LifecycleError> {
5196        let attempts = self
5197            .ports
5198            .store
5199            .attempts()
5200            .map_err(|_| LifecycleError::Journal)?;
5201        self.ports
5202            .packages
5203            .prune_obsolete_guarded(PruneAuthority::from_launch_request(guard), &attempts)
5204            .map_err(LifecycleError::Failed)?;
5205        #[cfg(target_os = "macos")]
5206        self.prune_abandoned_seeds(&attempts);
5207        Ok(())
5208    }
5209
5210    /// Remove the package seed from every runner root this host once placed a
5211    /// runtime in and no longer does: a changed host root override, a removed
5212    /// persistent policy, or one that moved its workspace.
5213    ///
5214    /// The roots ever used are the parents of the journal's runtime paths; the
5215    /// roots in use are the effective host root and every persistent policy's
5216    /// root. When the effective host root cannot be resolved nothing is
5217    /// removed, because a seed still in use cannot then be told apart. No
5218    /// launch clones from a seed under a root no policy uses, and it is
5219    /// best-effort: a seed left behind costs disk, never a launch.
5220    #[cfg(target_os = "macos")]
5221    fn prune_abandoned_seeds(&self, attempts: &[RunnerAttempt]) {
5222        let Ok(policies) = self.ports.store.policies() else {
5223            return;
5224        };
5225        let Ok(Some(host_root)) = self.resolvable_host_root() else {
5226            return;
5227        };
5228        let in_use: BTreeSet<PathBuf> = policies
5229            .iter()
5230            .filter_map(|policy| match policy.workspace_policy() {
5231                WorkspacePolicy::Persistent { root } => Some(canonical_or_raw(root.as_path())),
5232                WorkspacePolicy::Ephemeral => None,
5233            })
5234            .chain(std::iter::once(canonical_or_raw(host_root.as_path())))
5235            .collect();
5236        // Distinct parents first: the journal keeps every attempt ever made.
5237        let used: BTreeSet<&Path> = attempts
5238            .iter()
5239            .filter_map(|attempt| attempt.runtime_path().parent())
5240            .collect();
5241        for root in used.into_iter().map(canonical_or_raw) {
5242            if !in_use.contains(&root) {
5243                let _ = remove_runtime_tree(&root.join(PACKAGE_SEED_DIR));
5244            }
5245        }
5246    }
5247}
5248
5249#[cfg(target_os = "macos")]
5250fn canonical_or_raw(path: &Path) -> PathBuf {
5251    fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
5252}
5253
5254#[async_trait]
5255impl RunnerLauncher for LifecycleLauncher {
5256    async fn host_capacity(
5257        &self,
5258        host: &runner_manager_domain::model::Host,
5259    ) -> Result<std::num::NonZeroU16, crate::reconcile::HostCapacityError> {
5260        self.ports
5261            .store
5262            .host(host.id)
5263            .map_err(|_| crate::reconcile::HostCapacityError::JournalUnreadable)?
5264            .map(|current| current.host_capacity)
5265            .ok_or(crate::reconcile::HostCapacityError::MissingHost)
5266    }
5267
5268    async fn supervise(
5269        &self,
5270        policy: &ScalePolicy,
5271    ) -> Result<Vec<ReplacementIntent>, LaunchFailure> {
5272        LifecycleLauncher::supervise(self, policy)
5273            .await
5274            .map_err(|error| LaunchFailure::new(error.reason()))
5275    }
5276
5277    async fn attempts(&self) -> Result<Vec<RunnerAttempt>, LaunchFailure> {
5278        self.ports.store.attempts().map_err(|_| {
5279            LaunchFailure::new(FailureReason::Other(
5280                "attempt journal could not be read".into(),
5281            ))
5282        })
5283    }
5284
5285    async fn launch(&self, request: LaunchRequest<'_>) -> Result<RunnerAttempt, LaunchFailure> {
5286        self.reserve_attempt(request.policy, request.allocation_guard)
5287            .map_err(|error| LaunchFailure::new(error.reason()))
5288    }
5289
5290    async fn finish_launch(
5291        &self,
5292        policy: &ScalePolicy,
5293        attempt: RunnerAttempt,
5294        waits: ReservationWaits,
5295    ) -> Result<RunnerAttempt, LaunchFailure> {
5296        self.finish_attempt(policy, attempt, waits)
5297            .await
5298            .map_err(|error| LaunchFailure::new(error.reason()))
5299    }
5300
5301    async fn clean(&self, id: AttemptId) -> Result<(), LaunchFailure> {
5302        let mut attempt = self
5303            .ports
5304            .store
5305            .attempt(id)
5306            .map_err(|_| {
5307                LaunchFailure::new(FailureReason::Other(
5308                    "attempt journal could not be read".into(),
5309                ))
5310            })?
5311            .ok_or_else(|| {
5312                LaunchFailure::new(FailureReason::Other(
5313                    "attempt disappeared from the journal".into(),
5314                ))
5315            })?;
5316        self.clean_attempt(&mut attempt)
5317            .map_err(|error| LaunchFailure::new(error.reason()))?;
5318        self.cleanup_retries().succeeded(id);
5319        Ok(())
5320    }
5321}
5322
5323/// The name an attempt's runner registers with GitHub, which a
5324/// `workflow_job` webhook's `runner_name` repeats.
5325#[must_use]
5326pub fn runner_name(attempt: AttemptId) -> String {
5327    format!("runner-manager-{attempt}")
5328}
5329
5330/// How long reading a runner's diagnostics may take.
5331const EVIDENCE_DEADLINE: Duration = Duration::from_secs(2);
5332
5333/// What a native runner's own diagnostics say about whether it ran a job.
5334///
5335/// `Runner.Listener` writes `_diag/Runner_*.log` when it starts, and starts
5336/// `Runner.Worker` -- which writes `_diag/Worker_*.log` -- only for a job it has
5337/// acquired. So a worker log is proof a job ran, and a listener log without one
5338/// is proof none did. With neither, the directory is not where the listener
5339/// wrote, and nothing is concluded from it. `_diag` is scrubbed with the rest of
5340/// the runtime after every attempt, persistent slots included, so a log found
5341/// here belongs to this attempt.
5342#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5343enum JobEvidence {
5344    RanAJob,
5345    NoJob,
5346    Unknown,
5347}
5348
5349impl JobEvidence {
5350    fn of(runtime: &Path) -> Self {
5351        let diag = runtime.join("_diag");
5352        // Bounded: the daemon's loop runs on one thread, and a runtime on a
5353        // volume that does not answer (a hung disk, a pending macOS question)
5354        // must not stall it for longer than this.
5355        if runner_manager_platform::host_fitness::directory_responds(&diag, EVIDENCE_DEADLINE)
5356            != runner_manager_platform::host_fitness::Responsiveness::Responds
5357        {
5358            return Self::Unknown;
5359        }
5360        let Ok(entries) = fs::read_dir(diag) else {
5361            return Self::Unknown;
5362        };
5363        let mut listener = false;
5364        for entry in entries.flatten() {
5365            let name = entry.file_name();
5366            let name = name.to_string_lossy();
5367            if !name.ends_with(".log") {
5368                continue;
5369            }
5370            if name.starts_with("Worker_") {
5371                return Self::RanAJob;
5372            }
5373            listener |= name.starts_with("Runner_");
5374        }
5375        if listener { Self::NoJob } else { Self::Unknown }
5376    }
5377}
5378
5379/// The attempt's GitHub runner id: journalled, observed, or from the sidecar
5380/// written at registration.
5381fn known_runner_id(attempt: &RunnerAttempt, observed: Option<u64>) -> Option<u64> {
5382    attempt
5383        .github_runner_id()
5384        .or(observed)
5385        .or_else(|| read_runner_id(attempt.runtime_path()))
5386}
5387
5388fn read_runner_id(runtime: &Path) -> Option<u64> {
5389    fs::read_to_string(runtime.join(RUNNER_ID_FILE))
5390        .ok()?
5391        .trim()
5392        .parse()
5393        .ok()
5394}
5395
5396fn write_runner_id(runtime: &Path, runner_id: u64) -> Result<(), LifecycleError> {
5397    let target = runtime.join(RUNNER_ID_FILE);
5398    if let Some(existing) = read_runner_id(runtime) {
5399        return (existing == runner_id)
5400            .then_some(())
5401            .ok_or(LifecycleError::Journal);
5402    }
5403    let temporary = runtime.join(format!("{RUNNER_ID_FILE}.{}.tmp", uuid::Uuid::new_v4()));
5404    write_durable_file(&temporary, runner_id.to_string().as_bytes())
5405        .map_err(|_| LifecycleError::Journal)?;
5406    match fs::rename(&temporary, &target) {
5407        Ok(()) => sync_directory(runtime).map_err(|_| LifecycleError::Journal),
5408        Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
5409            let _ = fs::remove_file(&temporary);
5410            (read_runner_id(runtime) == Some(runner_id))
5411                .then_some(())
5412                .ok_or(LifecycleError::Journal)
5413        }
5414        Err(_) => {
5415            let _ = fs::remove_file(&temporary);
5416            Err(LifecycleError::Journal)
5417        }
5418    }
5419}
5420
5421fn write_durable_file(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
5422    let mut file = fs::OpenOptions::new()
5423        .create(true)
5424        .truncate(true)
5425        .write(true)
5426        .open(path)?;
5427    file.write_all(bytes)?;
5428    file.sync_all()?;
5429    let parent = path.parent().ok_or_else(|| {
5430        std::io::Error::new(
5431            std::io::ErrorKind::InvalidInput,
5432            "file has no parent directory",
5433        )
5434    })?;
5435    sync_directory(parent)
5436}
5437
5438#[cfg(unix)]
5439fn sync_directory(path: &Path) -> std::io::Result<()> {
5440    fs::File::open(path)?.sync_all()
5441}
5442
5443#[cfg(windows)]
5444fn sync_directory(path: &Path) -> std::io::Result<()> {
5445    use std::os::windows::fs::OpenOptionsExt;
5446
5447    const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000;
5448    const FILE_SHARE_ALL: u32 = 0x0000_0007;
5449    const GENERIC_WRITE: u32 = 0x4000_0000;
5450    fs::OpenOptions::new()
5451        .access_mode(GENERIC_WRITE)
5452        .share_mode(FILE_SHARE_ALL)
5453        .custom_flags(FILE_FLAG_BACKUP_SEMANTICS)
5454        .open(path)?
5455        .sync_all()
5456}
5457
5458#[cfg(test)]
5459mod tests {
5460    use super::*;
5461    use std::collections::BTreeSet;
5462    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
5463
5464    use crate::reconcile::{AllocationLock, InProcessAllocationLock};
5465    use runner_manager_domain::model::{Elapsed, TargetScope};
5466    use runner_manager_domain::store::SqliteStore;
5467    use runner_manager_github::jit::JitRunner;
5468    use runner_manager_testkit::clock::FakeClock;
5469    use runner_manager_testkit::fixtures;
5470
5471    #[test]
5472    fn production_provider_set_routes_every_implemented_isolated_backend() {
5473        let providers = PlatformExecutionProvider::new(HostId::from_u128(1));
5474        assert!(providers.isolated_provider(Backend::Auto).is_some());
5475        assert!(providers.isolated_provider(Backend::Oci).is_some());
5476        assert!(
5477            providers
5478                .isolated_provider(Backend::WindowsHyperVContainer)
5479                .is_some()
5480        );
5481        assert!(
5482            providers
5483                .isolated_provider(Backend::VirtualMachine)
5484                .is_none()
5485        );
5486    }
5487
5488    /// One event's fields, in the order they were recorded.
5489    type CapturedFields = Vec<(String, String)>;
5490
5491    /// Keeps the `(name, value)` fields of every `tracing` event emitted while
5492    /// it is installed.
5493    ///
5494    /// The names are kept and not just the rendered line, so a test can hold the
5495    /// event to `crate::logging`'s two rules -- the field allow-list and the
5496    /// value scrub -- instead of asserting that some string was passed to a
5497    /// macro.
5498    #[derive(Clone, Default)]
5499    struct CapturedEvents(std::sync::Arc<std::sync::Mutex<Vec<CapturedFields>>>);
5500
5501    impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for CapturedEvents {
5502        fn on_event(
5503            &self,
5504            event: &tracing::Event<'_>,
5505            _context: tracing_subscriber::layer::Context<'_, S>,
5506        ) {
5507            struct Collect(Vec<(String, String)>);
5508            impl tracing::field::Visit for Collect {
5509                fn record_debug(
5510                    &mut self,
5511                    field: &tracing::field::Field,
5512                    value: &dyn std::fmt::Debug,
5513                ) {
5514                    // `{value:?}` on a `&str` field would keep the quotes, and
5515                    // the redaction rules are about the value, not its literal.
5516                    self.0.push((
5517                        field.name().to_owned(),
5518                        format!("{value:?}").trim_matches('"').to_owned(),
5519                    ));
5520                }
5521            }
5522            let mut collected = Collect(Vec::new());
5523            event.record(&mut collected);
5524            self.0
5525                .lock()
5526                .expect("the capture mutex is not poisoned")
5527                .push(collected.0);
5528        }
5529    }
5530
5531    /// The regression behind a three-hour outage that showed nothing an operator
5532    /// could act on.
5533    ///
5534    /// A root the daemon cannot use refuses the launch *before*
5535    /// `record_allocation`, so no attempt row is ever written: `b2` has nothing
5536    /// to carry the failure on and `g2` has nothing to show it from. The
5537    /// lifecycle event keeps only the variant -- `failure_reason_kind` allows no
5538    /// free text on an event -- so the whole failure read
5539    /// `runner_start_failed reason=other`, once per poll, for hours.
5540    ///
5541    /// The assertion is deliberately made against the **production redaction
5542    /// rules** and not merely against what was emitted. An earlier attempt at
5543    /// this fix logged the rendered error on a `detail` field and passed a test
5544    /// exactly like this one, while shipping `detail="[redacted]"`: the field
5545    /// was not allow-listed, and had it been, the value is mostly paths and
5546    /// would have been scrubbed to `[path]`. A test that does not ask
5547    /// `crate::logging` what survives is a test that proves nothing.
5548    #[test]
5549    fn a_launch_the_runner_root_refused_names_the_cause_in_the_log_that_ships() {
5550        use runner_manager_platform::logging;
5551        use tracing_subscriber::layer::SubscriberExt as _;
5552
5553        let captured = CapturedEvents::default();
5554        let error = RunnerRootError::DeniedByPrivacyPolicy {
5555            requested: PathBuf::from("/Volumes/NVME/runners"),
5556            refused: PathBuf::from("/Volumes/NVME"),
5557            remediation: RootOwner::Host.remediation(),
5558        };
5559        let kind = error.kind();
5560
5561        let failure = tracing::subscriber::with_default(
5562            tracing_subscriber::registry().with(captured.clone()),
5563            || root_failure(error),
5564        );
5565
5566        // The reason still carries the whole sentence. Nothing on *this* path
5567        // renders it -- there is no attempt row, and the event carries only the
5568        // variant -- so this is a guard against a refactor that drops the detail
5569        // before some future surface can show it, and not a claim that one does.
5570        assert!(
5571            matches!(
5572                &failure,
5573                LifecycleError::Failed(FailureReason::Other(detail))
5574                    if detail.contains("/Volumes/NVME/runners")
5575                        && detail.contains("Full Disk Access")
5576            ),
5577            "the reason must still carry the detail: {failure:?}"
5578        );
5579
5580        let events = captured
5581            .0
5582            .lock()
5583            .expect("the capture mutex is not poisoned")
5584            .clone();
5585        let event = events
5586            .iter()
5587            .find(|fields| fields.iter().any(|(_, value)| value.contains(kind)))
5588            .unwrap_or_else(|| panic!("the refusal did not name its cause: {events:?}"));
5589
5590        // Held to the rules the real sink applies, by name and by value. An
5591        // earlier fix put the rendered error on an unlisted `detail` field and
5592        // shipped `[redacted]`; asserting only that something was emitted would
5593        // have passed then too.
5594        for (name, value) in event {
5595            assert!(
5596                logging::is_field_allowed(name),
5597                "`{name}` is not allow-listed, so it ships as `{}`: {event:?}",
5598                logging::REDACTION
5599            );
5600            assert_eq!(
5601                &logging::redact(value),
5602                value,
5603                "`{name}` does not survive value-shape scrubbing: {event:?}"
5604            );
5605        }
5606    }
5607
5608    /// A slot number, for the tests that name one.
5609    fn nz(slot: u16) -> NonZeroU16 {
5610        NonZeroU16::new(slot).expect("a positive slot")
5611    }
5612
5613    const JIT: &str = "eyJzZWNyZXQiOiJnaHBfRE9fTk9UX0xFQUsifQ==";
5614
5615    #[derive(Debug, Default)]
5616    struct FakeGithubLifecycle {
5617        registration_failures: Mutex<VecDeque<bool>>,
5618        observations: Mutex<VecDeque<LifecycleGithubObservation>>,
5619        registrations: AtomicUsize,
5620        remaining_runners: AtomicUsize,
5621        /// Every runner id `deregister` was asked to remove, in order. A count
5622        /// would not do: the assertions worth making are that the *right*
5623        /// registration was deleted and that it was deleted once.
5624        deregistrations: Mutex<Vec<u64>>,
5625        /// Set to make `deregister` answer `false`, standing for a GitHub that
5626        /// could not be reached at the moment the attempt concluded.
5627        deregistration_fails: AtomicBool,
5628        /// The journal to read *during* a registration, for the ordering
5629        /// assertion `02-target-architecture.md` makes: the slot lease is
5630        /// written "before package or GitHub effects". Reading it afterwards
5631        /// would pass even if the write happened second.
5632        journal: Mutex<Option<Arc<SqliteStore>>>,
5633        /// One entry per registration, in order.
5634        registration_facts: Mutex<Vec<RegistrationFact>>,
5635    }
5636
5637    /// What one JIT registration saw of the world at the moment it was issued.
5638    ///
5639    /// `runner_name` is what ties the other two fields to *one* attempt: with
5640    /// two allocators racing, "some slot was journalled" is a much weaker claim
5641    /// than "the slot this very request belongs to was journalled", and only the
5642    /// name distinguishes them.
5643    #[derive(Debug, Clone)]
5644    struct RegistrationFact {
5645        /// The persistent slots the journal already held.
5646        leased_slots: Vec<u16>,
5647        /// The `work_folder` the request carried.
5648        work_folder: String,
5649        /// The runner name the request carried, i.e. [`runner_name`] of the
5650        /// registering attempt.
5651        runner_name: String,
5652        journal_states: Vec<AttemptState>,
5653    }
5654
5655    impl FakeGithubLifecycle {
5656        fn fail(mut self, terminal: bool) -> Self {
5657            self.registration_failures
5658                .get_mut()
5659                .expect("unpoisoned")
5660                .push_back(terminal);
5661            self
5662        }
5663
5664        fn watch_journal(&self, store: Arc<SqliteStore>) {
5665            *self.journal.lock().unwrap() = Some(store);
5666        }
5667
5668        fn registration_facts(&self) -> Vec<RegistrationFact> {
5669            self.registration_facts.lock().unwrap().clone()
5670        }
5671
5672        fn observe(&self, observation: GithubRunnerObservation) {
5673            let observation = match observation {
5674                GithubRunnerObservation::Unreachable => LifecycleGithubObservation::unreachable(),
5675                GithubRunnerObservation::NotRegistered => {
5676                    LifecycleGithubObservation::not_registered()
5677                }
5678                GithubRunnerObservation::Registered { busy } => {
5679                    LifecycleGithubObservation::registered(73, busy)
5680                }
5681            };
5682            self.observations.lock().unwrap().push_back(observation);
5683        }
5684    }
5685
5686    #[async_trait]
5687    impl LifecycleGithub for FakeGithubLifecycle {
5688        async fn register(
5689            &self,
5690            _target: &ScaleTarget,
5691            request: &JitRunnerRequest,
5692            _cancel: &CancelToken,
5693        ) -> Result<JitRegistration, JitRequestFailure> {
5694            self.registrations.fetch_add(1, Ordering::SeqCst);
5695            if let Some(store) = self.journal.lock().unwrap().as_ref() {
5696                let attempts = store.attempts().expect("the journal is readable");
5697                let slots = attempts
5698                    .iter()
5699                    .filter_map(|attempt| attempt.workspace().slot_number())
5700                    .collect();
5701                self.registration_facts
5702                    .lock()
5703                    .unwrap()
5704                    .push(RegistrationFact {
5705                        leased_slots: slots,
5706                        work_folder: request.work_folder().to_string(),
5707                        runner_name: request.name().to_string(),
5708                        journal_states: attempts.iter().map(RunnerAttempt::state).collect(),
5709                    });
5710            }
5711            if let Some(terminal) = self.registration_failures.lock().unwrap().pop_front() {
5712                return Err(JitRequestFailure {
5713                    terminal,
5714                    reason: if terminal {
5715                        FailureReason::Other("GitHub refused JIT registration with 403".into())
5716                    } else {
5717                        FailureReason::JitRequestFailed
5718                    },
5719                    retry_after: None,
5720                });
5721            }
5722            self.remaining_runners.store(1, Ordering::SeqCst);
5723            Ok(JitRegistration::new(
5724                EncodedJitConfig::new(JIT),
5725                JitRunner {
5726                    id: 73,
5727                    name: request.name().to_string(),
5728                    os: "windows".into(),
5729                    status: "offline".into(),
5730                    busy: false,
5731                    runner_group_id: Some(1),
5732                    labels: request.labels().to_vec(),
5733                },
5734            ))
5735        }
5736
5737        async fn observe(
5738            &self,
5739            _target: &ScaleTarget,
5740            _attempt: AttemptId,
5741            _cancel: &CancelToken,
5742        ) -> LifecycleGithubObservation {
5743            let observation = self
5744                .observations
5745                .lock()
5746                .unwrap()
5747                .pop_front()
5748                .unwrap_or(LifecycleGithubObservation::not_registered());
5749            if observation.status == GithubRunnerObservation::NotRegistered {
5750                self.remaining_runners.store(0, Ordering::SeqCst);
5751            }
5752            observation
5753        }
5754
5755        async fn deregister(
5756            &self,
5757            _target: &ScaleTarget,
5758            runner_id: u64,
5759            _cancel: &CancelToken,
5760        ) -> bool {
5761            self.deregistrations.lock().unwrap().push(runner_id);
5762            if self.deregistration_fails.load(Ordering::SeqCst) {
5763                return false;
5764            }
5765            self.remaining_runners.store(0, Ordering::SeqCst);
5766            true
5767        }
5768    }
5769
5770    #[derive(Debug)]
5771    struct FakePackages {
5772        version: RunnerVersion,
5773        leases: Mutex<BTreeSet<AttemptId>>,
5774        materializations: AtomicUsize,
5775        materializing: AtomicUsize,
5776        peak_materializing: AtomicUsize,
5777        materialization_failures: AtomicUsize,
5778        release_failures: AtomicUsize,
5779        releases: AtomicUsize,
5780        prunes: AtomicUsize,
5781    }
5782
5783    impl Default for FakePackages {
5784        fn default() -> Self {
5785            Self {
5786                version: RunnerVersion::parse("2.330.0").unwrap(),
5787                leases: Mutex::new(BTreeSet::new()),
5788                materializations: AtomicUsize::new(0),
5789                materializing: AtomicUsize::new(0),
5790                peak_materializing: AtomicUsize::new(0),
5791                materialization_failures: AtomicUsize::new(0),
5792                release_failures: AtomicUsize::new(0),
5793                releases: AtomicUsize::new(0),
5794                prunes: AtomicUsize::new(0),
5795            }
5796        }
5797    }
5798
5799    impl FakePackages {
5800        fn fail_materializations(&self, count: usize) {
5801            self.materialization_failures.store(count, Ordering::SeqCst);
5802        }
5803
5804        fn fail_releases(&self, count: usize) {
5805            self.release_failures.store(count, Ordering::SeqCst);
5806        }
5807    }
5808
5809    #[async_trait]
5810    impl RuntimePackages for FakePackages {
5811        async fn materialize(
5812            &self,
5813            attempt: &RunnerAttempt,
5814            _timings: &mut PackageTimings,
5815        ) -> Result<RunnerVersion, FailureReason> {
5816            self.materializations.fetch_add(1, Ordering::SeqCst);
5817            let now = self.materializing.fetch_add(1, Ordering::SeqCst) + 1;
5818            self.peak_materializing.fetch_max(now, Ordering::SeqCst);
5819            for _ in 0..5 {
5820                tokio::task::yield_now().await;
5821            }
5822            self.materializing.fetch_sub(1, Ordering::SeqCst);
5823            if self
5824                .materialization_failures
5825                .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |left| {
5826                    if left > 0 { Some(left - 1) } else { None }
5827                })
5828                .is_ok()
5829            {
5830                return Err(FailureReason::Other(
5831                    "runner package materialization failed transiently".into(),
5832                ));
5833            }
5834            fs::create_dir_all(attempt.runtime_path()).unwrap();
5835            fs::write(attempt.runtime_path().join("runner-package"), b"verified").unwrap();
5836            self.leases.lock().unwrap().insert(attempt.id);
5837            Ok(self.version.clone())
5838        }
5839
5840        fn release(&self, attempt: AttemptId) -> Result<(), FailureReason> {
5841            if self
5842                .release_failures
5843                .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |left| {
5844                    if left > 0 { Some(left - 1) } else { None }
5845                })
5846                .is_ok()
5847            {
5848                return Err(FailureReason::Other(
5849                    "runner package lease could not be released".into(),
5850                ));
5851            }
5852            self.leases.lock().unwrap().remove(&attempt);
5853            self.releases.fetch_add(1, Ordering::SeqCst);
5854            Ok(())
5855        }
5856
5857        fn prune_obsolete_guarded(
5858            &self,
5859            _authority: PruneAuthority<'_>,
5860            _attempts: &[RunnerAttempt],
5861        ) -> Result<(), FailureReason> {
5862            self.prunes.fetch_add(1, Ordering::SeqCst);
5863            Ok(())
5864        }
5865    }
5866
5867    #[derive(Debug, Default)]
5868    struct FakeProcesses {
5869        alive: AtomicBool,
5870        completed_successfully: AtomicBool,
5871        spawns: AtomicUsize,
5872        spawn_failures: AtomicUsize,
5873        live_spawn_failure: AtomicBool,
5874        terminations: AtomicUsize,
5875        intent: AtomicBool,
5876        intent_failure: AtomicBool,
5877        actions: Mutex<Vec<&'static str>>,
5878        saw_secret: AtomicBool,
5879    }
5880
5881    impl FakeProcesses {
5882        fn fail_spawns(&self, count: usize) {
5883            self.spawn_failures.store(count, Ordering::SeqCst);
5884        }
5885
5886        fn fail_spawn_with_live_child(&self) {
5887            self.live_spawn_failure.store(true, Ordering::SeqCst);
5888        }
5889
5890        fn set_alive(&self, alive: bool) {
5891            self.alive.store(alive, Ordering::SeqCst);
5892        }
5893
5894        fn finish_successfully(&self) {
5895            self.completed_successfully.store(true, Ordering::SeqCst);
5896            self.alive.store(false, Ordering::SeqCst);
5897        }
5898
5899        fn fail_intent(&self) {
5900            self.intent_failure.store(true, Ordering::SeqCst);
5901        }
5902    }
5903
5904    impl ProcessSupervisor for FakeProcesses {
5905        fn spawn(
5906            &self,
5907            attempt: &RunnerAttempt,
5908            config: &EncodedJitConfig,
5909        ) -> Result<u32, ProcessStartFailure> {
5910            self.spawns.fetch_add(1, Ordering::SeqCst);
5911            // Model the production handoff on both paths: the sensitive file is
5912            // scoped to this call and absent when it returns.
5913            let handoff = RestrictiveHandoff::create(
5914                attempt.runtime_path(),
5915                SecretString::from(config.expose().to_owned()),
5916            )
5917            .unwrap();
5918            self.saw_secret
5919                .store(config.expose() == JIT, Ordering::SeqCst);
5920            let handoff_path = handoff.path().to_path_buf();
5921            let failing = self
5922                .spawn_failures
5923                .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |left| {
5924                    if left > 0 { Some(left - 1) } else { None }
5925                })
5926                .is_ok();
5927            drop(handoff);
5928            assert!(!handoff_path.exists(), "handoff must be absent on return");
5929            if self.live_spawn_failure.swap(false, Ordering::SeqCst) {
5930                self.alive.store(true, Ordering::SeqCst);
5931                return Err(ProcessStartFailure::after_spawn_live(4242));
5932            }
5933            if failing {
5934                return Err(ProcessStartFailure::before_spawn(
5935                    FailureReason::ProcessStartFailed,
5936                ));
5937            }
5938            self.alive.store(true, Ordering::SeqCst);
5939            Ok(4242)
5940        }
5941
5942        fn is_alive(&self, _attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
5943            self.actions.lock().unwrap().push("observe_process");
5944            Ok(self.alive.load(Ordering::SeqCst))
5945        }
5946
5947        fn recovered_pid(&self, _attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason> {
5948            Ok(self.alive.load(Ordering::SeqCst).then_some(4242))
5949        }
5950
5951        fn completed_successfully(&self, _attempt: &RunnerAttempt) -> bool {
5952            self.completed_successfully.load(Ordering::SeqCst)
5953        }
5954
5955        fn record_terminate_intent(&self, _attempt: &RunnerAttempt) -> Result<(), FailureReason> {
5956            self.actions.lock().unwrap().push("terminate_intent");
5957            if self.intent_failure.load(Ordering::SeqCst) {
5958                return Err(FailureReason::Other(
5959                    "terminate intent directory sync failed".into(),
5960                ));
5961            }
5962            self.intent.store(true, Ordering::SeqCst);
5963            Ok(())
5964        }
5965
5966        fn has_terminate_intent(&self, _attempt: &RunnerAttempt) -> bool {
5967            self.intent.load(Ordering::SeqCst)
5968        }
5969
5970        fn terminate(&self, _attempt: &RunnerAttempt) -> Result<(), FailureReason> {
5971            assert!(
5972                self.intent.load(Ordering::SeqCst),
5973                "the durable intent must exist before signalling"
5974            );
5975            self.actions.lock().unwrap().push("terminate");
5976            self.terminations.fetch_add(1, Ordering::SeqCst);
5977            self.alive.store(false, Ordering::SeqCst);
5978            Ok(())
5979        }
5980    }
5981
5982    #[derive(Debug, Default)]
5983    struct FakeIsolatedProvider {
5984        resources: Mutex<BTreeMap<AttemptId, (EnvironmentIdentity, EnvironmentState)>>,
5985        extra_resources: Mutex<Vec<EnvironmentIdentity>>,
5986        unsupported_host: AtomicBool,
5987        permission_denied: AtomicBool,
5988        incompatible_image: AtomicBool,
5989        prepare_failure: AtomicBool,
5990        recovery_unavailable: AtomicBool,
5991        destroy_deferred: AtomicBool,
5992        handoffs: AtomicUsize,
5993        completed_successfully: AtomicBool,
5994        native_calls: AtomicUsize,
5995        actions: Mutex<Vec<&'static str>>,
5996    }
5997
5998    impl FakeIsolatedProvider {
5999        fn identity(attempt: &RunnerAttempt, host: HostId) -> EnvironmentIdentity {
6000            let AttemptExecution::Isolated {
6001                provider_kind,
6002                resolved_image,
6003                generation,
6004                ..
6005            } = attempt.execution()
6006            else {
6007                panic!("isolated intent required")
6008            };
6009            EnvironmentIdentity::Isolated {
6010                host,
6011                attempt: attempt.id,
6012                provider_kind: *provider_kind,
6013                environment_id: format!("environment-{}", attempt.id),
6014                resolved_image: resolved_image.clone(),
6015                generation: generation.clone(),
6016            }
6017        }
6018
6019        fn resource_count(&self) -> usize {
6020            self.resources.lock().unwrap().len()
6021        }
6022    }
6023
6024    impl ExecutionProvider for FakeIsolatedProvider {
6025        fn probe(&self, policy: &ScalePolicy) -> ProviderCapability {
6026            if self.unsupported_host.load(Ordering::SeqCst) {
6027                ProviderCapability::Unsupported
6028            } else if self.permission_denied.load(Ordering::SeqCst) {
6029                ProviderCapability::PermissionDenied
6030            } else if policy.execution_policy().is_native() {
6031                ProviderCapability::Unsupported
6032            } else {
6033                ProviderCapability::Ready
6034            }
6035        }
6036
6037        fn resolve(
6038            &self,
6039            policy: &ScalePolicy,
6040        ) -> Result<Option<ResolvedEnvironment>, FailureReason> {
6041            let runner_manager_domain::execution::ExecutionPolicy::Isolated { image, .. } =
6042                policy.execution_policy()
6043            else {
6044                return Err(FailureReason::Other("isolated policy required".into()));
6045            };
6046            Ok(Some(ResolvedEnvironment {
6047                provider_kind: Backend::Oci,
6048                image: if self.incompatible_image.load(Ordering::SeqCst) {
6049                    ImageReference::new("vm-version:wrong-provider")
6050                        .expect("valid but incompatible")
6051                } else {
6052                    image.clone()
6053                },
6054            }))
6055        }
6056
6057        fn prepare(
6058            &self,
6059            attempt: &RunnerAttempt,
6060            policy: &ScalePolicy,
6061        ) -> Result<PreparedEnvironment, FailureReason> {
6062            assert_eq!(
6063                attempt.state(),
6064                AttemptState::Preparing,
6065                "intent must be durable before provider effect"
6066            );
6067            self.actions.lock().unwrap().push("prepare");
6068            let identity = Self::identity(attempt, policy.host_id);
6069            self.resources
6070                .lock()
6071                .unwrap()
6072                .insert(attempt.id, (identity.clone(), EnvironmentState::Starting));
6073            if self.prepare_failure.swap(false, Ordering::SeqCst) {
6074                return Err(FailureReason::Other("provider-secret-needle".into()));
6075            }
6076            Ok(PreparedEnvironment::isolated(attempt.id, identity))
6077        }
6078
6079        fn start(
6080            &self,
6081            prepared: PreparedEnvironment,
6082            attempt: &RunnerAttempt,
6083            handoff: OneTimeJitHandoff<'_>,
6084        ) -> Result<EnvironmentIdentity, ProcessStartFailure> {
6085            assert_eq!(prepared.attempt, attempt.id);
6086            assert_eq!(attempt.state(), AttemptState::JitReceived);
6087            assert_eq!(handoff.consume().expose(), JIT);
6088            self.handoffs.fetch_add(1, Ordering::SeqCst);
6089            self.actions.lock().unwrap().push("start");
6090            let mut resources = self.resources.lock().unwrap();
6091            let (identity, state) = resources.get_mut(&attempt.id).expect("prepared resource");
6092            *state = EnvironmentState::Running;
6093            Ok(identity.clone())
6094        }
6095
6096        fn inspect(&self, attempt: &RunnerAttempt) -> Result<EnvironmentState, FailureReason> {
6097            self.actions.lock().unwrap().push("inspect");
6098            Ok(self
6099                .resources
6100                .lock()
6101                .unwrap()
6102                .get(&attempt.id)
6103                .map_or(EnvironmentState::Missing, |(_, state)| *state))
6104        }
6105
6106        fn recover(&self, attempt: &RunnerAttempt) -> Result<EnvironmentState, FailureReason> {
6107            if self.recovery_unavailable.load(Ordering::SeqCst) {
6108                return Err(FailureReason::Other(
6109                    "provider discovery unavailable".into(),
6110                ));
6111            }
6112            self.inspect(attempt)
6113        }
6114
6115        fn owns(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
6116            Ok(self
6117                .resources
6118                .lock()
6119                .unwrap()
6120                .get(&attempt.id)
6121                .is_some_and(|(identity, _)| {
6122                    identity_matches_intent(attempt_host(identity), attempt, identity)
6123                }))
6124        }
6125
6126        fn stop(&self, attempt: &RunnerAttempt) -> Result<ProviderStop, FailureReason> {
6127            assert!(self.owns(attempt)?);
6128            self.actions.lock().unwrap().push("stop");
6129            if let Some((_, state)) = self.resources.lock().unwrap().get_mut(&attempt.id) {
6130                *state = EnvironmentState::Exited;
6131            }
6132            Ok(ProviderStop::Stopped)
6133        }
6134
6135        fn destroy(&self, attempt: &RunnerAttempt) -> Result<ProviderDestroy, FailureReason> {
6136            assert!(self.owns(attempt)?);
6137            self.actions.lock().unwrap().push("destroy");
6138            if self.destroy_deferred.load(Ordering::SeqCst) {
6139                return Ok(ProviderDestroy::Deferred("provider busy"));
6140            }
6141            self.resources.lock().unwrap().remove(&attempt.id);
6142            Ok(ProviderDestroy::Destroyed)
6143        }
6144
6145        fn enumerate_owned(&self, host: HostId) -> Vec<EnvironmentIdentity> {
6146            let mut owned = self
6147                .resources
6148                .lock()
6149                .unwrap()
6150                .values()
6151                .filter(|(identity, _)| attempt_host(identity) == host)
6152                .map(|(identity, _)| identity.clone())
6153                .collect::<Vec<_>>();
6154            owned.extend(
6155                self.extra_resources
6156                    .lock()
6157                    .unwrap()
6158                    .iter()
6159                    .filter(|identity| attempt_host(identity) == host)
6160                    .cloned(),
6161            );
6162            owned
6163        }
6164
6165        fn diagnostics(&self, _attempt: &RunnerAttempt) -> Vec<ProviderDiagnostic> {
6166            vec![ProviderDiagnostic::CleanupDeferred; 40]
6167        }
6168
6169        fn spawn(
6170            &self,
6171            _attempt: &RunnerAttempt,
6172            _config: &EncodedJitConfig,
6173        ) -> Result<u32, ProcessStartFailure> {
6174            self.native_calls.fetch_add(1, Ordering::SeqCst);
6175            panic!("isolated provider must never spawn natively")
6176        }
6177        fn is_alive(&self, _attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
6178            panic!("native fallback")
6179        }
6180        fn recovered_pid(&self, _attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason> {
6181            panic!("native fallback")
6182        }
6183        fn completed_successfully(&self, _attempt: &RunnerAttempt) -> bool {
6184            self.completed_successfully.load(Ordering::SeqCst)
6185        }
6186        fn record_terminate_intent(&self, _attempt: &RunnerAttempt) -> Result<(), FailureReason> {
6187            panic!("native fallback")
6188        }
6189        fn has_terminate_intent(&self, _attempt: &RunnerAttempt) -> bool {
6190            false
6191        }
6192        fn terminate(&self, _attempt: &RunnerAttempt) -> Result<(), FailureReason> {
6193            panic!("native fallback")
6194        }
6195    }
6196
6197    fn attempt_host(identity: &EnvironmentIdentity) -> HostId {
6198        let EnvironmentIdentity::Isolated { host, .. } = identity else {
6199            panic!("isolated identity")
6200        };
6201        *host
6202    }
6203
6204    #[derive(Debug, Default)]
6205    struct FakeDemand {
6206        answers: Mutex<VecDeque<bool>>,
6207    }
6208
6209    impl FakeDemand {
6210        fn answering(answers: impl IntoIterator<Item = bool>) -> Self {
6211            Self {
6212                answers: Mutex::new(answers.into_iter().collect()),
6213            }
6214        }
6215    }
6216
6217    #[async_trait]
6218    impl DemandPersistence for FakeDemand {
6219        async fn persists(&self, _policy: PolicyId) -> bool {
6220            self.answers.lock().unwrap().pop_front().unwrap_or(true)
6221        }
6222    }
6223
6224    #[derive(Debug, Default)]
6225    struct FakeDelay(Mutex<Vec<Duration>>);
6226
6227    #[async_trait]
6228    impl RetryDelay for FakeDelay {
6229        async fn wait(&self, duration: Duration) {
6230            self.0.lock().unwrap().push(duration);
6231        }
6232    }
6233
6234    #[tokio::test]
6235    async fn live_capacity_reads_the_updated_sqlite_host_and_rejects_a_missing_host() {
6236        let mut harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
6237        let path = harness._root.path().join("live-capacity.sqlite3");
6238        harness.store = Arc::new(SqliteStore::open(&path).unwrap());
6239        harness.store.put_host(&harness.host).unwrap();
6240        harness.launcher = harness.restart();
6241        assert_eq!(
6242            harness.launcher.host_capacity(&harness.host).await.unwrap(),
6243            harness.host.host_capacity
6244        );
6245        // A separate CLI/TUI connection updates the journal while the existing
6246        // launcher keeps its original store connection and startup host.
6247        let writer = SqliteStore::open(&path).unwrap();
6248        let mut updated = harness.host.clone();
6249        updated.host_capacity = std::num::NonZeroU16::new(12).unwrap();
6250        writer.put_host(&updated).unwrap();
6251        assert_eq!(
6252            harness
6253                .launcher
6254                .host_capacity(&harness.host)
6255                .await
6256                .unwrap()
6257                .get(),
6258            12
6259        );
6260
6261        updated.host_capacity = std::num::NonZeroU16::new(2).unwrap();
6262        writer.put_host(&updated).unwrap();
6263        assert_eq!(
6264            harness
6265                .launcher
6266                .host_capacity(&harness.host)
6267                .await
6268                .unwrap()
6269                .get(),
6270            2
6271        );
6272
6273        let mut missing = harness.host.clone();
6274        missing.id = HostId::from_u128(999);
6275        assert_eq!(
6276            harness.launcher.host_capacity(&missing).await,
6277            Err(crate::reconcile::HostCapacityError::MissingHost)
6278        );
6279    }
6280
6281    struct Harness {
6282        _root: tempfile::TempDir,
6283        app_paths: runner_manager_platform::paths::AppPaths,
6284        launcher: LifecycleLauncher,
6285        demand: Arc<dyn DemandPersistence>,
6286        store: Arc<SqliteStore>,
6287        github: Arc<FakeGithubLifecycle>,
6288        packages: Arc<FakePackages>,
6289        processes: Arc<FakeProcesses>,
6290        clock: Arc<FakeClock>,
6291        events: Arc<AttemptEventLog>,
6292        reconcile_events: Arc<crate::reconcile::EventLog>,
6293        delay: Arc<FakeDelay>,
6294        host: runner_manager_domain::model::Host,
6295        policy: ScalePolicy,
6296        allocation_lock: InProcessAllocationLock,
6297        /// The repository persistent root, once one is configured.
6298        workspace_root: Option<LocalAbsolutePath>,
6299    }
6300
6301    impl Harness {
6302        fn new(github: FakeGithubLifecycle, demand: Arc<dyn DemandPersistence>) -> Self {
6303            let root = tempfile::tempdir().unwrap();
6304            let paths = runner_manager_platform::paths::AppPaths::rooted_at(root.path());
6305            paths.create_all().unwrap();
6306            let policy = fixtures::policy()
6307                .repository("octo/repo")
6308                .autoscale("home", 2)
6309                .active()
6310                .build();
6311            // A unit harness must never inspect or write the workstation's
6312            // production runner root. On Windows that is C:\rman, which is
6313            // intentionally inaccessible to an ordinary account after a boot
6314            // service is installed. Using it made the same suite pass on a
6315            // clean CI image and fail on a correctly secured developer host.
6316            let host_root = root.path().join("host-root");
6317            fs::create_dir_all(&host_root).unwrap();
6318            let mut host = fixtures::host().build();
6319            host.runner_root_override = Some(
6320                LocalAbsolutePath::new(host_root.to_str().expect("a UTF-8 temporary path"))
6321                    .expect("a local absolute host root"),
6322            );
6323            let store = Arc::new(SqliteStore::open_in_memory().unwrap());
6324            store.put_host(&host).unwrap();
6325            let github = Arc::new(github);
6326            let packages = Arc::new(FakePackages::default());
6327            let processes = Arc::new(FakeProcesses::default());
6328            let clock = Arc::new(FakeClock::default());
6329            let events = Arc::new(AttemptEventLog::default());
6330            let reconcile_events = Arc::new(crate::reconcile::EventLog::new());
6331            let delay = Arc::new(FakeDelay::default());
6332            let ports = LifecyclePorts {
6333                store: Arc::clone(&store) as Arc<dyn Store>,
6334                github: Arc::clone(&github) as Arc<dyn LifecycleGithub>,
6335                packages: Arc::clone(&packages) as Arc<dyn RuntimePackages>,
6336                processes: Arc::clone(&processes) as Arc<dyn ProcessSupervisor>,
6337                clock: Arc::clone(&clock) as Arc<dyn Clock>,
6338                demand: Arc::clone(&demand),
6339                delay: Arc::clone(&delay) as Arc<dyn RetryDelay>,
6340                events: Arc::clone(&events) as Arc<dyn AttemptEventSink>,
6341                reconcile_events: Arc::clone(&reconcile_events) as Arc<dyn EventSink>,
6342            };
6343            let launcher = Self::launcher_over(policy.host_id, &paths, ports);
6344            Self {
6345                _root: root,
6346                app_paths: paths,
6347                launcher,
6348                demand,
6349                store,
6350                github,
6351                packages,
6352                processes,
6353                clock,
6354                events,
6355                reconcile_events,
6356                delay,
6357                host,
6358                policy,
6359                allocation_lock: InProcessAllocationLock::new(),
6360                workspace_root: None,
6361            }
6362        }
6363
6364        /// Put disposable attempts under a host root of this harness's own.
6365        ///
6366        /// Without it the launcher resolves the *platform* default, which on
6367        /// Windows is `%SystemDrive%\rman` — a real directory on the machine
6368        /// running the suite. Every test added by `c2` places its files inside
6369        /// its own temporary directory instead.
6370        fn with_host_runner_root(mut self) -> Self {
6371            let host_root = self.host_root();
6372            fs::create_dir_all(&host_root).unwrap();
6373            self.host.runner_root_override = Some(
6374                LocalAbsolutePath::new(host_root.to_str().expect("a UTF-8 temporary path"))
6375                    .expect("a local absolute host root"),
6376            );
6377            self.store.put_host(&self.host).unwrap();
6378            self
6379        }
6380
6381        /// Opt this harness's repository into a persistent workspace (D4).
6382        fn with_persistent_workspace(mut self, capacity: u16) -> Self {
6383            self = self.with_host_runner_root();
6384            let root = self._root.path().join("persist");
6385            let root = LocalAbsolutePath::new(root.to_str().expect("a UTF-8 temporary path"))
6386                .expect("a local absolute workspace root");
6387            self.policy = fixtures::policy()
6388                .repository("octo/repo")
6389                .autoscale("home", capacity)
6390                .active()
6391                .build();
6392            self.policy
6393                .set_workspace_policy(
6394                    WorkspacePolicy::persistent(root.clone(), TargetScope::Repository)
6395                        .expect("a repository may be persistent"),
6396                )
6397                .expect("a repository may be persistent");
6398            self.workspace_root = Some(root);
6399            // The journal's copy, so that cleanup's cross-check against a
6400            // surviving policy is exercised rather than skipped.
6401            self.store.insert_policy(&self.policy).unwrap();
6402            self
6403        }
6404
6405        fn workspace_root(&self) -> &LocalAbsolutePath {
6406            self.workspace_root
6407                .as_ref()
6408                .expect("this harness configured a persistent workspace")
6409        }
6410
6411        fn slot_path(&self, slot: u16) -> PathBuf {
6412            self.workspace_root().as_path().join(format!("s{slot}"))
6413        }
6414
6415        fn host_root(&self) -> PathBuf {
6416            self._root.path().join("host-root")
6417        }
6418
6419        fn attempt(&self, id: AttemptId) -> RunnerAttempt {
6420            self.store
6421                .attempt(id)
6422                .unwrap()
6423                .expect("the attempt is journalled")
6424        }
6425
6426        /// Conclude an attempt and run the real cleanup over it.
6427        fn conclude(&self, id: AttemptId) -> RunnerAttempt {
6428            let mut attempt = self.attempt(id);
6429            attempt
6430                .conclude(
6431                    AttemptOutcome::failed(FailureReason::ProcessExitedUnexpectedly),
6432                    self.clock.now(),
6433                )
6434                .unwrap();
6435            self.store.record_attempt(&attempt).unwrap();
6436            attempt
6437        }
6438
6439        async fn cleanup_retaining_work(&self, id: AttemptId) {
6440            self.conclude(id);
6441            self.launcher
6442                .clean(id)
6443                .await
6444                .expect("the slot is scrubbed and the lease released");
6445        }
6446
6447        /// The launcher configuration every launcher in this harness shares, so
6448        /// that the one a restart mints cannot drift from the original.
6449        fn launcher_over(
6450            host: HostId,
6451            paths: &runner_manager_platform::paths::AppPaths,
6452            ports: LifecyclePorts,
6453        ) -> LifecycleLauncher {
6454            LifecycleLauncher::new(
6455                host,
6456                paths.clone(),
6457                paths.logs_dir(),
6458                1,
6459                RecoveryTimeouts::new(
6460                    Elapsed::seconds(10),
6461                    Elapsed::seconds(10),
6462                    Elapsed::seconds(10),
6463                ),
6464                RetryPolicy::bounded(3, Duration::from_millis(10), Duration::from_millis(25)),
6465                ports,
6466            )
6467        }
6468
6469        /// The same journal, the same directories, a launcher that remembers
6470        /// nothing — which is what a daemon restart is.
6471        fn restart(&self) -> LifecycleLauncher {
6472            Self::launcher_over(
6473                self.policy.host_id,
6474                &self.app_paths,
6475                LifecyclePorts {
6476                    store: Arc::clone(&self.store) as Arc<dyn Store>,
6477                    github: Arc::clone(&self.github) as Arc<dyn LifecycleGithub>,
6478                    packages: Arc::clone(&self.packages) as Arc<dyn RuntimePackages>,
6479                    processes: Arc::clone(&self.processes) as Arc<dyn ProcessSupervisor>,
6480                    clock: Arc::clone(&self.clock) as Arc<dyn Clock>,
6481                    demand: Arc::clone(&self.demand),
6482                    delay: Arc::clone(&self.delay) as Arc<dyn RetryDelay>,
6483                    events: Arc::clone(&self.events) as Arc<dyn AttemptEventSink>,
6484                    reconcile_events: Arc::clone(&self.reconcile_events) as Arc<dyn EventSink>,
6485                },
6486            )
6487        }
6488
6489        async fn ready(&self) {
6490            self.launcher
6491                .recover_startup(std::slice::from_ref(&self.policy))
6492                .await
6493                .unwrap();
6494        }
6495
6496        async fn launch(&self) -> RunnerAttempt {
6497            self.launch_result().await.unwrap()
6498        }
6499
6500        async fn launch_result(&self) -> Result<RunnerAttempt, LaunchFailure> {
6501            self.launch_after_waiting(Duration::ZERO).await
6502        }
6503
6504        async fn launch_after_waiting(
6505            &self,
6506            lock_wait: Duration,
6507        ) -> Result<RunnerAttempt, LaunchFailure> {
6508            let reserved = self.reserve().await?;
6509            self.launcher
6510                .finish_launch(
6511                    &self.policy,
6512                    reserved,
6513                    ReservationWaits {
6514                        lock: lock_wait,
6515                        order: Duration::from_secs(2),
6516                    },
6517                )
6518                .await
6519        }
6520
6521        /// A whole launch through `launcher`, as a reconciliation pass makes it.
6522        async fn launch_through(
6523            &self,
6524            launcher: &LifecycleLauncher,
6525        ) -> Result<RunnerAttempt, LaunchFailure> {
6526            let reserved = self.reserve_through(launcher).await?;
6527            launcher
6528                .finish_launch(&self.policy, reserved, ReservationWaits::default())
6529                .await
6530        }
6531
6532        async fn reserve(&self) -> Result<RunnerAttempt, LaunchFailure> {
6533            self.reserve_through(&self.launcher).await
6534        }
6535
6536        async fn reserve_through(
6537            &self,
6538            launcher: &LifecycleLauncher,
6539        ) -> Result<RunnerAttempt, LaunchFailure> {
6540            let guard = self.allocation_lock.acquire().await.unwrap();
6541            launcher
6542                .launch(LaunchRequest {
6543                    host: &self.host,
6544                    policy: &self.policy,
6545                    allocation_guard: &guard,
6546                })
6547                .await
6548        }
6549
6550        fn only_attempt(&self) -> RunnerAttempt {
6551            self.store.attempts().unwrap().into_iter().next().unwrap()
6552        }
6553    }
6554
6555    fn isolated_harness() -> Harness {
6556        use runner_manager_domain::execution::{ExecutionPolicy, ResourceLimits};
6557        let mut harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
6558            .with_host_runner_root();
6559        let image =
6560            ImageReference::new(format!("registry.example/runner@sha256:{}", "a".repeat(64)))
6561                .expect("pinned image");
6562        harness
6563            .policy
6564            .set_execution_policy(ExecutionPolicy::Isolated {
6565                backend: Backend::Oci,
6566                image,
6567                resources: ResourceLimits {
6568                    cpu_millis: 1000,
6569                    memory_mib: 1024,
6570                    disk_mib: 4096,
6571                },
6572            })
6573            .expect("isolated policy");
6574        harness.github.watch_journal(Arc::clone(&harness.store));
6575        harness
6576    }
6577
6578    fn launcher_with_isolated(
6579        harness: &Harness,
6580        provider: Arc<FakeIsolatedProvider>,
6581    ) -> LifecycleLauncher {
6582        Harness::launcher_over(
6583            harness.policy.host_id,
6584            &harness.app_paths,
6585            LifecyclePorts {
6586                store: Arc::clone(&harness.store) as Arc<dyn Store>,
6587                github: Arc::clone(&harness.github) as Arc<dyn LifecycleGithub>,
6588                packages: Arc::clone(&harness.packages) as Arc<dyn RuntimePackages>,
6589                processes: provider as Arc<dyn ExecutionProvider>,
6590                clock: Arc::clone(&harness.clock) as Arc<dyn Clock>,
6591                demand: Arc::clone(&harness.demand),
6592                delay: Arc::clone(&harness.delay) as Arc<dyn RetryDelay>,
6593                events: Arc::clone(&harness.events) as Arc<dyn AttemptEventSink>,
6594                reconcile_events: Arc::clone(&harness.reconcile_events) as Arc<dyn EventSink>,
6595            },
6596        )
6597    }
6598
6599    #[tokio::test]
6600    async fn isolated_policy_and_journal_never_enter_native_provider_or_jit() {
6601        use runner_manager_domain::execution::{
6602            AttemptExecution, Backend, ExecutionPolicy, ImageReference, ResourceLimits,
6603        };
6604
6605        let mut harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
6606            .with_host_runner_root();
6607        let image =
6608            ImageReference::new(format!("registry.example/runner@sha256:{}", "a".repeat(64)))
6609                .expect("pinned image");
6610        harness
6611            .policy
6612            .set_execution_policy(ExecutionPolicy::Isolated {
6613                backend: Backend::Oci,
6614                image: image.clone(),
6615                resources: ResourceLimits {
6616                    cpu_millis: 1000,
6617                    memory_mib: 1024,
6618                    disk_mib: 4096,
6619                },
6620            })
6621            .expect("valid isolated profile");
6622
6623        harness.ready().await;
6624        assert_eq!(
6625            harness.processes.probe(&harness.policy),
6626            ProviderCapability::Unsupported
6627        );
6628        let failure = harness
6629            .launch_result()
6630            .await
6631            .expect_err("provider is unavailable");
6632        assert_eq!(
6633            failure.reason,
6634            FailureReason::IsolationProvider(IsolationProviderFailure::Unsupported)
6635        );
6636
6637        let runtime = harness.host_root().join("isolated-owned-environment");
6638        fs::create_dir_all(&runtime).expect("provider resource fixture");
6639        fs::write(runtime.join("owned-marker"), "non-secret").expect("fixture marker");
6640        let mut attempt = RunnerAttempt::allocate(
6641            AttemptId::from_u128(0x123),
6642            harness.policy.id,
6643            runtime.clone(),
6644            harness.clock.now(),
6645        );
6646        attempt
6647            .allocate_execution(AttemptExecution::Isolated {
6648                provider_kind: Backend::Oci,
6649                environment_id: None,
6650                resolved_image: image,
6651                generation: "generation-123".into(),
6652            })
6653            .expect("isolated intent");
6654        attempt
6655            .prepared_environment("environment-123".into())
6656            .expect("prepared identity");
6657        harness
6658            .store
6659            .record_attempt(&attempt)
6660            .expect("durable isolated journal");
6661
6662        harness
6663            .launcher
6664            .recover_startup(std::slice::from_ref(&harness.policy))
6665            .await
6666            .expect("unsupported isolated cleanup is profile-local");
6667        let failure = harness
6668            .launch_result()
6669            .await
6670            .expect_err("provider unavailable for this policy");
6671        assert_eq!(
6672            failure.reason,
6673            FailureReason::IsolationProvider(IsolationProviderFailure::Unsupported)
6674        );
6675        harness
6676            .launcher
6677            .supervise(&harness.policy)
6678            .await
6679            .expect("unavailable attempt is deferred");
6680        assert!(harness.launcher.clean(attempt.id).await.is_err());
6681        assert_eq!(
6682            harness.store.attempt(attempt.id).unwrap().unwrap().state(),
6683            AttemptState::CleanupDeferred
6684        );
6685        assert!(matches!(
6686            harness.launcher.recover_startup(&[]).await,
6687            Err(LifecycleError::RecoveryIncomplete)
6688        ));
6689        assert!(
6690            runtime.join("owned-marker").is_file(),
6691            "native cleanup touched the isolated environment"
6692        );
6693        assert!(
6694            harness.processes.actions.lock().unwrap().is_empty(),
6695            "native recovery inspected an isolated identity"
6696        );
6697        assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 0);
6698        assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
6699        assert_eq!(harness.packages.materializations.load(Ordering::SeqCst), 0);
6700    }
6701
6702    #[tokio::test]
6703    async fn isolated_prepare_is_durable_before_jit_and_start_uses_one_handoff() {
6704        let harness = isolated_harness();
6705        let provider = Arc::new(FakeIsolatedProvider::default());
6706        let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
6707        launcher
6708            .recover_startup(std::slice::from_ref(&harness.policy))
6709            .await
6710            .expect("ready");
6711        let attempt = harness
6712            .launch_through(&launcher)
6713            .await
6714            .expect("isolated launch");
6715        assert_eq!(attempt.state(), AttemptState::Starting);
6716        assert_eq!(provider.resource_count(), 1);
6717        assert_eq!(provider.handoffs.load(Ordering::SeqCst), 1);
6718        assert_eq!(provider.native_calls.load(Ordering::SeqCst), 0);
6719        assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 1);
6720        let fact = harness
6721            .github
6722            .registration_facts()
6723            .pop()
6724            .expect("JIT registration");
6725        assert_eq!(
6726            fact.journal_states,
6727            vec![AttemptState::Prepared],
6728            "provider readiness preceded GitHub JIT"
6729        );
6730        assert_eq!(
6731            harness
6732                .events
6733                .events()
6734                .into_iter()
6735                .filter_map(|event| match event {
6736                    AttemptEvent::State { state, .. } => Some(state),
6737                    _ => None,
6738                })
6739                .collect::<Vec<_>>(),
6740            vec![
6741                AttemptState::Allocated,
6742                AttemptState::Preparing,
6743                AttemptState::Prepared,
6744                AttemptState::JitReceived,
6745                AttemptState::Starting
6746            ]
6747        );
6748        let restarted = launcher_with_isolated(&harness, Arc::clone(&provider));
6749        restarted
6750            .recover_startup(std::slice::from_ref(&harness.policy))
6751            .await
6752            .expect("adopt live sandbox");
6753        assert_eq!(provider.resource_count(), 1);
6754        assert_eq!(
6755            harness.github.registrations.load(Ordering::SeqCst),
6756            1,
6757            "restart cannot issue duplicate JIT"
6758        );
6759        harness
6760            .github
6761            .observe(GithubRunnerObservation::Registered { busy: true });
6762        restarted
6763            .supervise(&harness.policy)
6764            .await
6765            .expect("job observed");
6766        assert_eq!(
6767            harness.store.attempt(attempt.id).unwrap().unwrap().state(),
6768            AttemptState::Busy
6769        );
6770        provider
6771            .resources
6772            .lock()
6773            .unwrap()
6774            .get_mut(&attempt.id)
6775            .unwrap()
6776            .1 = EnvironmentState::Exited;
6777        harness
6778            .github
6779            .observe(GithubRunnerObservation::NotRegistered);
6780        restarted
6781            .supervise(&harness.policy)
6782            .await
6783            .expect("terminal cleanup");
6784        assert_eq!(provider.resource_count(), 0);
6785        assert_eq!(
6786            harness.store.attempt(attempt.id).unwrap().unwrap().state(),
6787            AttemptState::Cleaned
6788        );
6789    }
6790
6791    #[tokio::test]
6792    async fn attached_isolated_job_exit_is_recorded_as_completed() {
6793        let harness = isolated_harness();
6794        let provider = Arc::new(FakeIsolatedProvider::default());
6795        let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
6796        launcher
6797            .recover_startup(std::slice::from_ref(&harness.policy))
6798            .await
6799            .unwrap();
6800        let attempt = harness.launch_through(&launcher).await.unwrap();
6801        harness
6802            .github
6803            .observe(GithubRunnerObservation::Registered { busy: true });
6804        launcher.supervise(&harness.policy).await.unwrap();
6805        provider
6806            .resources
6807            .lock()
6808            .unwrap()
6809            .get_mut(&attempt.id)
6810            .unwrap()
6811            .1 = EnvironmentState::Exited;
6812        provider
6813            .completed_successfully
6814            .store(true, Ordering::SeqCst);
6815        harness
6816            .github
6817            .observe(GithubRunnerObservation::NotRegistered);
6818        launcher.supervise(&harness.policy).await.unwrap();
6819        let cleaned = harness.store.attempt(attempt.id).unwrap().unwrap();
6820        assert_eq!(cleaned.outcome(), Some(&AttemptOutcome::CompletedJob));
6821        assert_eq!(cleaned.state(), AttemptState::Cleaned);
6822    }
6823
6824    #[tokio::test]
6825    async fn isolated_prepare_failure_stays_local_and_crash_gap_recovers_owned_resource() {
6826        let harness = isolated_harness();
6827        let provider = Arc::new(FakeIsolatedProvider::default());
6828        provider.prepare_failure.store(true, Ordering::SeqCst);
6829        let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
6830        launcher
6831            .recover_startup(std::slice::from_ref(&harness.policy))
6832            .await
6833            .expect("ready");
6834        let failure = harness
6835            .launch_through(&launcher)
6836            .await
6837            .expect_err("prepare failed after creating resource");
6838        assert_eq!(
6839            failure.reason,
6840            FailureReason::IsolationProvider(IsolationProviderFailure::RuntimeOperationFailed)
6841        );
6842        assert_eq!(provider.resource_count(), 1);
6843        assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
6844        assert!(
6845            !harness
6846                .store
6847                .dump_text()
6848                .unwrap()
6849                .contains("provider-secret-needle")
6850        );
6851        let id = harness.store.attempts().unwrap()[0].id;
6852        assert_eq!(
6853            harness.store.attempt(id).unwrap().unwrap().outcome(),
6854            Some(&AttemptOutcome::failed(FailureReason::IsolationProvider(
6855                IsolationProviderFailure::RuntimeOperationFailed
6856            )))
6857        );
6858        let restarted = launcher_with_isolated(&harness, Arc::clone(&provider));
6859        restarted
6860            .recover_startup(std::slice::from_ref(&harness.policy))
6861            .await
6862            .expect("profile-local recovery");
6863        assert_eq!(
6864            provider.resource_count(),
6865            0,
6866            "the exact owned resource was destroyed once"
6867        );
6868        assert_eq!(
6869            harness.store.attempt(id).unwrap().unwrap().state(),
6870            AttemptState::Cleaned
6871        );
6872        let diagnostic =
6873            fs::read_to_string(restarted.diagnostics_root.join(format!("{id}.log"))).unwrap();
6874        assert!(diagnostic.contains("reason=isolation_runtime_operation_failed"));
6875        assert!(!diagnostic.contains("provider-secret-needle"));
6876        assert_eq!(
6877            provider
6878                .actions
6879                .lock()
6880                .unwrap()
6881                .iter()
6882                .filter(|action| **action == "destroy")
6883                .count(),
6884            1
6885        );
6886        assert_eq!(provider.native_calls.load(Ordering::SeqCst), 0);
6887    }
6888
6889    #[test]
6890    fn isolation_capability_and_adapter_failures_keep_closed_categories() {
6891        let cases = [
6892            (
6893                ProviderCapability::NotInstalled,
6894                IsolationProviderFailure::NotInstalled,
6895            ),
6896            (
6897                ProviderCapability::PermissionDenied,
6898                IsolationProviderFailure::PermissionDenied,
6899            ),
6900            (
6901                ProviderCapability::ImageUnavailableOrIncompatible,
6902                IsolationProviderFailure::ImageUnavailableOrIncompatible,
6903            ),
6904            (
6905                ProviderCapability::DiskQuotaUnavailable,
6906                IsolationProviderFailure::DiskQuotaUnavailable,
6907            ),
6908            (
6909                ProviderCapability::Degraded,
6910                IsolationProviderFailure::Degraded,
6911            ),
6912        ];
6913        for (capability, category) in cases {
6914            assert_eq!(
6915                capability.refusal(),
6916                FailureReason::IsolationProvider(category)
6917            );
6918        }
6919        let raw = FailureReason::Other("ghp_secret_from_provider".into());
6920        let safe = safe_isolation_reason(raw, IsolationProviderFailure::RuntimeOperationFailed);
6921        assert_eq!(
6922            safe,
6923            FailureReason::IsolationProvider(IsolationProviderFailure::RuntimeOperationFailed)
6924        );
6925        assert!(!safe.to_string().contains("ghp_secret"));
6926        assert_eq!(
6927            safe_isolation_reason(
6928                FailureReason::IsolationProvider(IsolationProviderFailure::PermissionDenied),
6929                IsolationProviderFailure::RuntimeOperationFailed,
6930            ),
6931            FailureReason::IsolationProvider(IsolationProviderFailure::PermissionDenied)
6932        );
6933    }
6934
6935    #[tokio::test]
6936    async fn isolated_permission_and_image_failures_stop_before_jit_without_native_fallback() {
6937        for image_failure in [false, true] {
6938            let harness = isolated_harness();
6939            let provider = Arc::new(FakeIsolatedProvider::default());
6940            provider
6941                .permission_denied
6942                .store(!image_failure, Ordering::SeqCst);
6943            provider
6944                .incompatible_image
6945                .store(image_failure, Ordering::SeqCst);
6946            let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
6947            launcher
6948                .recover_startup(std::slice::from_ref(&harness.policy))
6949                .await
6950                .unwrap();
6951            let error = harness
6952                .launch_through(&launcher)
6953                .await
6954                .expect_err("pre-JIT refusal");
6955            let expected = if image_failure {
6956                "resolved image is incompatible"
6957            } else {
6958                "permission denied"
6959            };
6960            assert!(error.reason.to_string().contains(expected));
6961            assert!(
6962                harness.store.attempts().unwrap().is_empty(),
6963                "no allocation was journalled"
6964            );
6965            assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
6966            assert_eq!(provider.handoffs.load(Ordering::SeqCst), 0);
6967            assert_eq!(provider.native_calls.load(Ordering::SeqCst), 0);
6968        }
6969    }
6970
6971    #[tokio::test]
6972    async fn unsupported_host_stops_before_jit_without_allocation_or_native_fallback() {
6973        let harness = isolated_harness();
6974        let provider = Arc::new(FakeIsolatedProvider::default());
6975        provider.unsupported_host.store(true, Ordering::SeqCst);
6976        let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
6977        launcher
6978            .recover_startup(std::slice::from_ref(&harness.policy))
6979            .await
6980            .unwrap();
6981        let error = harness
6982            .launch_through(&launcher)
6983            .await
6984            .expect_err("unsupported host must be refused before JIT");
6985
6986        assert_eq!(
6987            error.reason,
6988            FailureReason::IsolationProvider(IsolationProviderFailure::Unsupported)
6989        );
6990        assert!(harness.store.attempts().unwrap().is_empty());
6991        assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
6992        assert_eq!(provider.handoffs.load(Ordering::SeqCst), 0);
6993        assert_eq!(provider.native_calls.load(Ordering::SeqCst), 0);
6994        assert!(provider.actions.lock().unwrap().is_empty());
6995    }
6996
6997    #[tokio::test]
6998    async fn no_journalled_environment_id_waits_for_provider_absence_proof() {
6999        let harness = isolated_harness();
7000        let provider = Arc::new(FakeIsolatedProvider::default());
7001        let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
7002        let runtime = harness.host_root().join("prepare-without-identity");
7003        fs::create_dir_all(&runtime).unwrap();
7004        let mut attempt = RunnerAttempt::allocate(
7005            AttemptId::from_u128(0x9191),
7006            harness.policy.id,
7007            runtime,
7008            harness.clock.now(),
7009        );
7010        let runner_manager_domain::execution::ExecutionPolicy::Isolated { image, .. } =
7011            harness.policy.execution_policy()
7012        else {
7013            unreachable!()
7014        };
7015        attempt
7016            .allocate_execution(AttemptExecution::Isolated {
7017                provider_kind: Backend::Oci,
7018                environment_id: None,
7019                resolved_image: image.clone(),
7020                generation: "prepare-generation".into(),
7021            })
7022            .unwrap();
7023        attempt.begin_prepare(harness.clock.now()).unwrap();
7024        attempt
7025            .conclude(
7026                AttemptOutcome::failed(FailureReason::Other("prepare interrupted".into())),
7027                harness.clock.now(),
7028            )
7029            .unwrap();
7030        harness.store.record_attempt(&attempt).unwrap();
7031
7032        provider.recovery_unavailable.store(true, Ordering::SeqCst);
7033        launcher
7034            .clean(attempt.id)
7035            .await
7036            .expect_err("absence unproven");
7037        let deferred = harness.store.attempt(attempt.id).unwrap().unwrap();
7038        assert_eq!(deferred.state(), AttemptState::CleanupDeferred);
7039        assert!(deferred.counts_against_capacity());
7040
7041        provider.recovery_unavailable.store(false, Ordering::SeqCst);
7042        launcher.clean(attempt.id).await.expect("absence proven");
7043        assert_eq!(
7044            harness.store.attempt(attempt.id).unwrap().unwrap().state(),
7045            AttemptState::Cleaned
7046        );
7047    }
7048
7049    #[tokio::test]
7050    async fn isolated_terminal_capacity_waits_for_confirmed_absence_and_generation_matches() {
7051        let harness = isolated_harness();
7052        let provider = Arc::new(FakeIsolatedProvider::default());
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 mut attempt = harness.launch_through(&launcher).await.expect("launch");
7059        attempt
7060            .conclude(
7061                AttemptOutcome::failed(FailureReason::ProcessStartFailed),
7062                harness.clock.now(),
7063            )
7064            .unwrap();
7065        harness.store.record_attempt(&attempt).unwrap();
7066        provider.destroy_deferred.store(true, Ordering::SeqCst);
7067        launcher
7068            .clean(attempt.id)
7069            .await
7070            .expect_err("provider deferred destroy");
7071        let deferred = harness.store.attempt(attempt.id).unwrap().unwrap();
7072        assert_eq!(deferred.state(), AttemptState::CleanupDeferred);
7073        assert!(deferred.counts_against_capacity());
7074        assert_eq!(provider.resource_count(), 1);
7075        provider.destroy_deferred.store(false, Ordering::SeqCst);
7076        let restarted = launcher_with_isolated(&harness, Arc::clone(&provider));
7077        restarted
7078            .recover_startup(std::slice::from_ref(&harness.policy))
7079            .await
7080            .expect("retry cleanup");
7081        assert_eq!(provider.resource_count(), 0);
7082        assert!(
7083            !harness
7084                .store
7085                .attempt(attempt.id)
7086                .unwrap()
7087                .unwrap()
7088                .counts_against_capacity()
7089        );
7090
7091        // A reused runtime name with a different generation is not ours to delete.
7092        let second_id = AttemptId::from_u128(0x9999);
7093        let second_runtime = harness.host_root().join("generation-mismatch");
7094        fs::create_dir_all(&second_runtime).unwrap();
7095        let mut terminal = RunnerAttempt::allocate(
7096            second_id,
7097            harness.policy.id,
7098            second_runtime,
7099            harness.clock.now(),
7100        );
7101        let runner_manager_domain::execution::ExecutionPolicy::Isolated { image, .. } =
7102            harness.policy.execution_policy()
7103        else {
7104            unreachable!()
7105        };
7106        terminal
7107            .allocate_execution(AttemptExecution::Isolated {
7108                provider_kind: Backend::Oci,
7109                environment_id: None,
7110                resolved_image: image.clone(),
7111                generation: "expected-generation".into(),
7112            })
7113            .unwrap();
7114        let mut stranger = FakeIsolatedProvider::identity(&terminal, harness.policy.host_id);
7115        let EnvironmentIdentity::Isolated { environment_id, .. } = &stranger else {
7116            unreachable!()
7117        };
7118        terminal.begin_prepare(harness.clock.now()).unwrap();
7119        terminal
7120            .prepared_environment(environment_id.clone())
7121            .unwrap();
7122        terminal.mark_prepared(harness.clock.now()).unwrap();
7123        terminal.jit_received(harness.clock.now()).unwrap();
7124        terminal.started_isolated(harness.clock.now()).unwrap();
7125        terminal
7126            .conclude(
7127                AttemptOutcome::failed(FailureReason::ProcessStartFailed),
7128                harness.clock.now(),
7129            )
7130            .unwrap();
7131        harness.store.record_attempt(&terminal).unwrap();
7132        let EnvironmentIdentity::Isolated { generation, .. } = &mut stranger else {
7133            unreachable!()
7134        };
7135        *generation = "another-generation".into();
7136        provider
7137            .resources
7138            .lock()
7139            .unwrap()
7140            .insert(second_id, (stranger, EnvironmentState::Running));
7141        restarted
7142            .clean(terminal.id)
7143            .await
7144            .expect_err("ownership mismatch quarantined");
7145        assert_eq!(provider.resource_count(), 1);
7146        assert_eq!(
7147            harness.store.attempt(terminal.id).unwrap().unwrap().state(),
7148            AttemptState::CleanupDeferred
7149        );
7150        assert_eq!(provider.native_calls.load(Ordering::SeqCst), 0);
7151    }
7152
7153    #[tokio::test]
7154    async fn isolated_restart_at_each_transition_adopts_or_destroys_one_resource() {
7155        for stage in [
7156            AttemptState::Allocated,
7157            AttemptState::Preparing,
7158            AttemptState::Prepared,
7159            AttemptState::JitReceived,
7160            AttemptState::Starting,
7161            AttemptState::Idle,
7162            AttemptState::Busy,
7163            AttemptState::Finished,
7164            AttemptState::Failed,
7165            AttemptState::Orphaned,
7166            AttemptState::Destroying,
7167            AttemptState::CleanupDeferred,
7168        ] {
7169            let harness = isolated_harness();
7170            let provider = Arc::new(FakeIsolatedProvider::default());
7171            let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
7172            let id = AttemptId::from_u128(0x1000 + stage as u128);
7173            let runtime = harness.host_root().join(format!("phase-{stage}"));
7174            fs::create_dir_all(&runtime).unwrap();
7175            let mut attempt =
7176                RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
7177            let runner_manager_domain::execution::ExecutionPolicy::Isolated { image, .. } =
7178                harness.policy.execution_policy()
7179            else {
7180                unreachable!()
7181            };
7182            attempt
7183                .allocate_execution(AttemptExecution::Isolated {
7184                    provider_kind: Backend::Oci,
7185                    environment_id: None,
7186                    resolved_image: image.clone(),
7187                    generation: format!("generation-{stage}"),
7188                })
7189                .unwrap();
7190            if stage != AttemptState::Allocated {
7191                attempt.begin_prepare(harness.clock.now()).unwrap();
7192            }
7193            let identity = FakeIsolatedProvider::identity(&attempt, harness.policy.host_id);
7194            if stage != AttemptState::Allocated {
7195                let resource_state = if matches!(
7196                    stage,
7197                    AttemptState::Finished
7198                        | AttemptState::Failed
7199                        | AttemptState::Orphaned
7200                        | AttemptState::Destroying
7201                        | AttemptState::CleanupDeferred
7202                ) {
7203                    EnvironmentState::Exited
7204                } else {
7205                    EnvironmentState::Running
7206                };
7207                provider
7208                    .resources
7209                    .lock()
7210                    .unwrap()
7211                    .insert(id, (identity.clone(), resource_state));
7212            }
7213            if !matches!(stage, AttemptState::Allocated | AttemptState::Preparing) {
7214                let EnvironmentIdentity::Isolated { environment_id, .. } = &identity else {
7215                    unreachable!()
7216                };
7217                attempt
7218                    .prepared_environment(environment_id.clone())
7219                    .unwrap();
7220                attempt.mark_prepared(harness.clock.now()).unwrap();
7221            }
7222            if matches!(
7223                stage,
7224                AttemptState::JitReceived
7225                    | AttemptState::Starting
7226                    | AttemptState::Idle
7227                    | AttemptState::Busy
7228                    | AttemptState::Finished
7229                    | AttemptState::Failed
7230                    | AttemptState::Orphaned
7231                    | AttemptState::Destroying
7232                    | AttemptState::CleanupDeferred
7233            ) {
7234                attempt.jit_received(harness.clock.now()).unwrap();
7235            }
7236            if matches!(
7237                stage,
7238                AttemptState::Starting
7239                    | AttemptState::Idle
7240                    | AttemptState::Busy
7241                    | AttemptState::Finished
7242                    | AttemptState::Failed
7243                    | AttemptState::Orphaned
7244                    | AttemptState::Destroying
7245                    | AttemptState::CleanupDeferred
7246            ) {
7247                attempt.started_isolated(harness.clock.now()).unwrap();
7248            }
7249            if matches!(
7250                stage,
7251                AttemptState::Idle | AttemptState::Busy | AttemptState::Finished
7252            ) {
7253                attempt.registered_idle(73, harness.clock.now()).unwrap();
7254            }
7255            if matches!(stage, AttemptState::Busy | AttemptState::Finished) {
7256                attempt.assigned_job(73, harness.clock.now()).unwrap();
7257            }
7258            if matches!(
7259                stage,
7260                AttemptState::Finished
7261                    | AttemptState::Failed
7262                    | AttemptState::Orphaned
7263                    | AttemptState::Destroying
7264                    | AttemptState::CleanupDeferred
7265            ) {
7266                let outcome = match stage {
7267                    AttemptState::Finished => AttemptOutcome::CompletedJob,
7268                    AttemptState::Orphaned => AttemptOutcome::Orphaned,
7269                    _ => AttemptOutcome::failed(FailureReason::ProcessStartFailed),
7270                };
7271                attempt.conclude(outcome, harness.clock.now()).unwrap();
7272            }
7273            if matches!(
7274                stage,
7275                AttemptState::Destroying | AttemptState::CleanupDeferred
7276            ) {
7277                attempt.begin_destroy(harness.clock.now()).unwrap();
7278            }
7279            if stage == AttemptState::CleanupDeferred {
7280                attempt.defer_cleanup(harness.clock.now()).unwrap();
7281            }
7282            harness.store.record_attempt(&attempt).unwrap();
7283            launcher
7284                .recover_startup(std::slice::from_ref(&harness.policy))
7285                .await
7286                .unwrap_or_else(|error| panic!("restart from {stage}: {error}"));
7287            let resumed = harness.store.attempt(id).unwrap().unwrap();
7288            if matches!(
7289                stage,
7290                AttemptState::Allocated
7291                    | AttemptState::Preparing
7292                    | AttemptState::Prepared
7293                    | AttemptState::Finished
7294                    | AttemptState::Failed
7295                    | AttemptState::Orphaned
7296                    | AttemptState::Destroying
7297                    | AttemptState::CleanupDeferred
7298            ) {
7299                assert_eq!(
7300                    resumed.state(),
7301                    AttemptState::Cleaned,
7302                    "{stage} must retire after restart"
7303                );
7304            } else {
7305                assert!(resumed.counts_against_capacity(), "live {stage} is adopted");
7306                assert_eq!(provider.resource_count(), 1);
7307                provider.resources.lock().unwrap().get_mut(&id).unwrap().1 =
7308                    EnvironmentState::Exited;
7309                launcher.supervise(&harness.policy).await.unwrap();
7310                assert_eq!(
7311                    harness.store.attempt(id).unwrap().unwrap().state(),
7312                    AttemptState::Cleaned
7313                );
7314            }
7315            assert_eq!(
7316                provider.resource_count(),
7317                0,
7318                "{stage} resource cleaned exactly once"
7319            );
7320            let destroyed = provider
7321                .actions
7322                .lock()
7323                .unwrap()
7324                .iter()
7325                .filter(|action| **action == "destroy")
7326                .count();
7327            assert_eq!(
7328                destroyed,
7329                usize::from(stage != AttemptState::Allocated),
7330                "{stage} destroy count"
7331            );
7332            assert_eq!(
7333                harness.github.registrations.load(Ordering::SeqCst),
7334                0,
7335                "restart from {stage} cannot duplicate JIT"
7336            );
7337            assert_eq!(provider.native_calls.load(Ordering::SeqCst), 0);
7338        }
7339    }
7340
7341    #[tokio::test]
7342    async fn duplicate_provider_ownership_is_quarantined_until_unambiguous() {
7343        let harness = isolated_harness();
7344        let provider = Arc::new(FakeIsolatedProvider::default());
7345        let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
7346        let id = AttemptId::from_u128(0x7777);
7347        let runtime = harness.host_root().join("duplicate-provider-ownership");
7348        fs::create_dir_all(&runtime).unwrap();
7349        let mut attempt =
7350            RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
7351        let runner_manager_domain::execution::ExecutionPolicy::Isolated { image, .. } =
7352            harness.policy.execution_policy()
7353        else {
7354            unreachable!()
7355        };
7356        attempt
7357            .allocate_execution(AttemptExecution::Isolated {
7358                provider_kind: Backend::Oci,
7359                environment_id: None,
7360                resolved_image: image.clone(),
7361                generation: "duplicate-generation".into(),
7362            })
7363            .unwrap();
7364        attempt.begin_prepare(harness.clock.now()).unwrap();
7365        harness.store.record_attempt(&attempt).unwrap();
7366        let first = FakeIsolatedProvider::identity(&attempt, harness.policy.host_id);
7367        let mut second = first.clone();
7368        let EnvironmentIdentity::Isolated { environment_id, .. } = &mut second else {
7369            unreachable!()
7370        };
7371        *environment_id = "another-environment".into();
7372        provider
7373            .resources
7374            .lock()
7375            .unwrap()
7376            .insert(id, (first, EnvironmentState::Running));
7377        provider.extra_resources.lock().unwrap().push(second);
7378        launcher
7379            .recover_startup(std::slice::from_ref(&harness.policy))
7380            .await
7381            .expect("duplicate is local");
7382        let deferred = harness.store.attempt(id).unwrap().unwrap();
7383        assert_eq!(deferred.state(), AttemptState::CleanupDeferred);
7384        assert!(deferred.counts_against_capacity());
7385        assert_eq!(provider.resource_count(), 1);
7386        assert_eq!(
7387            provider
7388                .actions
7389                .lock()
7390                .unwrap()
7391                .iter()
7392                .filter(|action| **action == "destroy")
7393                .count(),
7394            0
7395        );
7396        provider.extra_resources.lock().unwrap().clear();
7397        // A tolerated cleanup failure is retried after its backoff delay.
7398        harness.clock.advance_secs(31);
7399        launcher
7400            .recover_startup(std::slice::from_ref(&harness.policy))
7401            .await
7402            .expect("single identity recovered");
7403        assert_eq!(provider.resource_count(), 0);
7404        assert_eq!(
7405            harness.store.attempt(id).unwrap().unwrap().state(),
7406            AttemptState::Cleaned
7407        );
7408    }
7409
7410    #[tokio::test]
7411    async fn provider_resource_without_live_journal_is_surfaced_but_never_deleted() {
7412        let harness = isolated_harness();
7413        let provider = Arc::new(FakeIsolatedProvider::default());
7414        let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
7415        let id = AttemptId::from_u128(0x8888);
7416        let runtime = harness.host_root().join("orphan-provider-resource");
7417        fs::create_dir_all(&runtime).unwrap();
7418        let mut attempt =
7419            RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
7420        let runner_manager_domain::execution::ExecutionPolicy::Isolated { image, .. } =
7421            harness.policy.execution_policy()
7422        else {
7423            unreachable!()
7424        };
7425        attempt
7426            .allocate_execution(AttemptExecution::Isolated {
7427                provider_kind: Backend::Oci,
7428                environment_id: None,
7429                resolved_image: image.clone(),
7430                generation: "orphan-generation".into(),
7431            })
7432            .unwrap();
7433        let identity = FakeIsolatedProvider::identity(&attempt, harness.policy.host_id);
7434        provider
7435            .resources
7436            .lock()
7437            .unwrap()
7438            .insert(id, (identity, EnvironmentState::Running));
7439        launcher
7440            .recover_startup(std::slice::from_ref(&harness.policy))
7441            .await
7442            .expect("orphan cannot block host");
7443        assert_eq!(provider.resource_count(), 1);
7444        assert!(
7445            harness
7446                .events
7447                .events()
7448                .contains(&AttemptEvent::QuarantinedOwnedResource { attempt: id })
7449        );
7450        assert_eq!(
7451            provider
7452                .actions
7453                .lock()
7454                .unwrap()
7455                .iter()
7456                .filter(|action| **action == "destroy")
7457                .count(),
7458            0
7459        );
7460
7461        let EnvironmentIdentity::Isolated { environment_id, .. } = provider
7462            .resources
7463            .lock()
7464            .unwrap()
7465            .get(&id)
7466            .unwrap()
7467            .0
7468            .clone()
7469        else {
7470            unreachable!()
7471        };
7472        attempt.begin_prepare(harness.clock.now()).unwrap();
7473        attempt.prepared_environment(environment_id).unwrap();
7474        attempt.mark_prepared(harness.clock.now()).unwrap();
7475        attempt.jit_received(harness.clock.now()).unwrap();
7476        attempt.started_isolated(harness.clock.now()).unwrap();
7477        attempt
7478            .conclude(
7479                AttemptOutcome::failed(FailureReason::ProcessStartFailed),
7480                harness.clock.now(),
7481            )
7482            .unwrap();
7483        attempt.begin_destroy(harness.clock.now()).unwrap();
7484        attempt.clean_isolated(harness.clock.now()).unwrap();
7485        harness.store.record_attempt(&attempt).unwrap();
7486        launcher
7487            .recover_startup(std::slice::from_ref(&harness.policy))
7488            .await
7489            .expect("cleaned row cannot authorize deletion");
7490        assert_eq!(provider.resource_count(), 1);
7491        assert_eq!(
7492            provider
7493                .actions
7494                .lock()
7495                .unwrap()
7496                .iter()
7497                .filter(|action| **action == "destroy")
7498                .count(),
7499            0
7500        );
7501    }
7502
7503    /// The wiring the three-hour outage needed and did not have.
7504    ///
7505    /// A root that refuses a launch does so before `record_allocation`, so no
7506    /// attempt row carries it, and the daemon's log scrubs the paths out of the
7507    /// sentence. The record this asserts is the only surface left, and
7508    /// `service status` reads it -- so if this wiring is ever dropped, the
7509    /// failure goes back to reading `runner_start_failed reason=other` once per
7510    /// poll and nothing else.
7511    #[tokio::test]
7512    async fn a_root_that_refuses_a_launch_is_recorded_and_cleared_when_one_succeeds() {
7513        use runner_manager_platform::service::{clear_runner_root_refusal, runner_root_refusals};
7514
7515        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
7516            .with_host_runner_root();
7517        harness.ready().await;
7518
7519        // A root under two directories that do not exist: refused by the
7520        // preflight, for a reason that has nothing to do with this platform, so
7521        // the assertion holds on all three.
7522        let unusable = harness
7523            ._root
7524            .path()
7525            .join("absent")
7526            .join("deeper")
7527            .join("runners");
7528        let mut host = harness.host.clone();
7529        host.runner_root_override = Some(
7530            LocalAbsolutePath::new(unusable.to_str().expect("a UTF-8 temporary path"))
7531                .expect("a local absolute host root"),
7532        );
7533        harness.store.put_host(&host).unwrap();
7534
7535        let failure = harness
7536            .launch_result()
7537            .await
7538            .expect_err("a root whose parents are missing cannot hold a runner");
7539        assert!(
7540            matches!(failure.reason, FailureReason::Other(_)),
7541            "{failure:?}"
7542        );
7543
7544        let refusals = runner_root_refusals(&harness.app_paths).expect("readable");
7545        let refusal = refusals
7546            .first()
7547            .expect("the refusal reached the one surface that can hold it");
7548        assert_eq!(refusal.policy, harness.policy.id.to_string());
7549        assert_eq!(refusal.kind, "missing_parents");
7550        assert!(
7551            refusal.root.contains("runners") && refusal.detail.contains("runners"),
7552            "the directory must be named in full: {refusal:?}"
7553        );
7554
7555        // And a root that works clears it, so a host that has been fixed stops
7556        // reporting a fault it no longer has.
7557        harness.store.put_host(&harness.host).unwrap();
7558        harness.launch().await;
7559        assert!(
7560            runner_root_refusals(&harness.app_paths)
7561                .expect("readable")
7562                .is_empty(),
7563            "a successful placement clears that policy's record"
7564        );
7565
7566        clear_runner_root_refusal(&harness.app_paths, &harness.policy.id.to_string())
7567            .expect("cleanup");
7568    }
7569
7570    #[tokio::test]
7571    async fn a_launch_reports_how_long_each_step_took() {
7572        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7573        harness.ready().await;
7574        let started = harness.launch().await;
7575        let events = harness.events.events();
7576        let launched = events
7577            .iter()
7578            .position(|event| matches!(event, AttemptEvent::Launched { attempt, .. } if *attempt == started.id))
7579            .expect("a successful launch reports its step timings");
7580        let starting = events
7581            .iter()
7582            .position(|event| matches!(event, AttemptEvent::State { attempt, state: AttemptState::Starting } if *attempt == started.id))
7583            .unwrap();
7584        assert!(starting < launched, "timings follow the start: {events:?}");
7585    }
7586
7587    #[tokio::test]
7588    async fn a_failed_launch_still_reports_its_step_timings() {
7589        let harness = Harness::new(
7590            FakeGithubLifecycle::default().fail(true),
7591            Arc::new(PersistentDemand),
7592        );
7593        harness.ready().await;
7594        let failed = harness.launch_after_waiting(Duration::from_secs(90)).await;
7595        assert!(failed.is_err());
7596
7597        let timings = harness
7598            .events
7599            .events()
7600            .into_iter()
7601            .find_map(|event| match event {
7602                AttemptEvent::Launched {
7603                    started: false,
7604                    timings,
7605                    ..
7606                } => Some(timings),
7607                _ => None,
7608            })
7609            .expect("a launch that failed at registration reports its timings");
7610        assert_eq!(timings.lock_wait, Duration::from_secs(90));
7611        assert_eq!(timings.order_wait, Duration::from_secs(2));
7612        assert_eq!(timings.spawn, Duration::ZERO, "spawn was never reached");
7613        assert!(timings.total() >= Duration::from_secs(92));
7614    }
7615
7616    #[cfg(target_os = "macos")]
7617    #[tokio::test]
7618    async fn a_launch_removes_the_seed_of_a_root_no_longer_in_use() {
7619        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7620        harness.ready().await;
7621
7622        // A runtime this host once placed under a root it no longer uses.
7623        let old_root = harness._root.path().join("old-root");
7624        let old_runtime = old_root.join("abandoned");
7625        fs::create_dir_all(old_root.join(PACKAGE_SEED_DIR).join("2.335.0")).unwrap();
7626        fs::create_dir_all(&old_runtime).unwrap();
7627        let old = RunnerAttempt::allocate(
7628            AttemptId::new_random(),
7629            harness.policy.id,
7630            &old_runtime,
7631            harness.clock.now(),
7632        );
7633        harness.store.record_attempt(&old).unwrap();
7634        let kept = harness.host_root().join(PACKAGE_SEED_DIR);
7635        fs::create_dir_all(kept.join("2.336.0")).unwrap();
7636
7637        harness.launch().await;
7638
7639        assert!(
7640            !old_root.join(PACKAGE_SEED_DIR).exists(),
7641            "the abandoned root's seed is removed"
7642        );
7643        assert!(old_runtime.exists(), "only the seed is touched");
7644        assert!(kept.exists(), "the host root's own seed is kept");
7645    }
7646
7647    #[tokio::test]
7648    async fn a_job_walks_every_state_and_cleans_every_artifact() {
7649        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7650        harness.ready().await;
7651        let started = harness.launch().await;
7652        assert_eq!(started.state(), AttemptState::Starting);
7653        assert_eq!(read_runner_id(started.runtime_path()), Some(73));
7654
7655        harness
7656            .github
7657            .observe(GithubRunnerObservation::Registered { busy: false });
7658        harness.launcher.supervise(&harness.policy).await.unwrap();
7659        assert_eq!(harness.only_attempt().state(), AttemptState::Idle);
7660
7661        harness
7662            .github
7663            .observe(GithubRunnerObservation::Registered { busy: true });
7664        harness.launcher.supervise(&harness.policy).await.unwrap();
7665        assert_eq!(harness.only_attempt().state(), AttemptState::Busy);
7666
7667        harness.processes.finish_successfully();
7668        harness
7669            .github
7670            .observe(GithubRunnerObservation::NotRegistered);
7671        harness.launcher.supervise(&harness.policy).await.unwrap();
7672        let cleaned = harness.only_attempt();
7673        assert_eq!(cleaned.state(), AttemptState::Cleaned);
7674        assert_eq!(cleaned.outcome(), Some(&AttemptOutcome::CompletedJob));
7675        assert!(!started.runtime_path().exists());
7676        assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 1);
7677        assert_eq!(harness.github.remaining_runners.load(Ordering::SeqCst), 0);
7678
7679        let states: Vec<_> = harness
7680            .events
7681            .events()
7682            .into_iter()
7683            .filter_map(|event| match event {
7684                AttemptEvent::State { state, .. } => Some(state),
7685                _ => None,
7686            })
7687            .collect();
7688        assert_eq!(
7689            states,
7690            vec![
7691                AttemptState::Allocated,
7692                AttemptState::JitReceived,
7693                AttemptState::Starting,
7694                AttemptState::Idle,
7695                AttemptState::Busy,
7696                AttemptState::Finished,
7697                AttemptState::Cleaned,
7698            ]
7699        );
7700    }
7701
7702    #[tokio::test]
7703    async fn a_cleaned_ephemeral_attempt_reaps_a_directory_recreated_after_cleanup() {
7704        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7705        harness.ready().await;
7706        let started = harness.launch().await;
7707        let runtime = started.runtime_path().to_path_buf();
7708
7709        harness
7710            .github
7711            .observe(GithubRunnerObservation::Registered { busy: true });
7712        harness.launcher.supervise(&harness.policy).await.unwrap();
7713        harness.processes.finish_successfully();
7714        harness
7715            .github
7716            .observe(GithubRunnerObservation::NotRegistered);
7717        harness.launcher.supervise(&harness.policy).await.unwrap();
7718
7719        assert_eq!(harness.attempt(started.id).state(), AttemptState::Cleaned);
7720        assert!(!runtime.exists());
7721        assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 1);
7722
7723        // Reproduce a late orphan creating output after the successful removal
7724        // and journal transition. The next ordinary policy supervision must
7725        // remove it without replaying package release or another state change.
7726        let residue = runtime.join("_work").join("late-node-process");
7727        fs::create_dir_all(&residue).unwrap();
7728        fs::write(residue.join("node_modules.lock"), b"late residue").unwrap();
7729
7730        harness.launcher.supervise(&harness.policy).await.unwrap();
7731
7732        assert!(!runtime.exists(), "late ephemeral residue is reaped");
7733        assert_eq!(harness.attempt(started.id).state(), AttemptState::Cleaned);
7734        assert_eq!(
7735            harness.packages.releases.load(Ordering::SeqCst),
7736            1,
7737            "reaping residue does not release the package lease twice"
7738        );
7739    }
7740
7741    #[tokio::test]
7742    async fn locked_late_residue_never_blocks_startup_recovery() {
7743        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7744        harness.ready().await;
7745        let started = harness.launch().await;
7746        let runtime = started.runtime_path().to_path_buf();
7747
7748        harness
7749            .github
7750            .observe(GithubRunnerObservation::Registered { busy: true });
7751        harness.launcher.supervise(&harness.policy).await.unwrap();
7752        harness.processes.finish_successfully();
7753        harness
7754            .github
7755            .observe(GithubRunnerObservation::NotRegistered);
7756        harness.launcher.supervise(&harness.policy).await.unwrap();
7757        assert_eq!(harness.attempt(started.id).state(), AttemptState::Cleaned);
7758
7759        let held = runtime.join("_work").join("late-node-process");
7760        fs::create_dir_all(&held).unwrap();
7761        fs::write(held.join("node_modules.lock"), b"late residue").unwrap();
7762        let Some(block) = BlockedDeletion::inject(&held) else {
7763            eprintln!(
7764                "skipped: this account cannot be refused a deletion, so locked late residue cannot be injected"
7765            );
7766            return;
7767        };
7768
7769        let restarted = harness.restart();
7770        restarted
7771            .recover_startup(std::slice::from_ref(&harness.policy))
7772            .await
7773            .expect("late residue cannot take the daemon offline");
7774        assert!(runtime.exists(), "the locked residue remains for a retry");
7775        assert!(
7776            harness
7777                .reconcile_events
7778                .events()
7779                .iter()
7780                .any(|event| matches!(
7781                    event,
7782                    LifecycleEvent::AttemptCleanFailed {
7783                        attempt,
7784                        reason: "late_ephemeral_workspace_could_not_be_removed",
7785                        ..
7786                    } if *attempt == started.id
7787                )),
7788            "the non-blocking cleanup failure remains visible"
7789        );
7790
7791        block.release();
7792        // A tolerated cleanup failure is retried after its backoff delay.
7793        harness.clock.advance_secs(31);
7794        restarted
7795            .supervise(&harness.policy)
7796            .await
7797            .expect("the ordinary retry succeeds after the lock is released");
7798        assert!(!runtime.exists(), "the retry removes the late residue");
7799        assert_eq!(harness.attempt(started.id).state(), AttemptState::Cleaned);
7800        assert_eq!(
7801            harness.packages.releases.load(Ordering::SeqCst),
7802            1,
7803            "retrying residue never releases the package lease twice"
7804        );
7805    }
7806
7807    #[tokio::test]
7808    async fn mixed_locked_cleanup_residue_isolated_from_the_whole_startup_pass() {
7809        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7810        harness.ready().await;
7811
7812        // The first attempt is terminal but its initial cleanup cannot finish.
7813        // This is the state left by the original failed job on the affected
7814        // workstation.
7815        let terminal = harness.launch().await;
7816        let terminal_runtime = terminal.runtime_path().to_path_buf();
7817        harness.conclude(terminal.id);
7818        let Some(terminal_block) = BlockedDeletion::inject(&terminal_runtime.join("late-child"))
7819        else {
7820            eprintln!("skipped: this account cannot inject the two independent deletion refusals");
7821            return;
7822        };
7823
7824        // The second attempt was cleaned successfully, then a child recreated
7825        // part of `_work`. Both real-world residue shapes existed together in
7826        // the incident, and neither may short-circuit recovery of the other.
7827        let cleaned = harness.launch().await;
7828        let cleaned_runtime = cleaned.runtime_path().to_path_buf();
7829        harness.conclude(cleaned.id);
7830        harness
7831            .launcher
7832            .clean(cleaned.id)
7833            .await
7834            .expect("the second attempt initially cleans");
7835        let Some(cleaned_block) =
7836            BlockedDeletion::inject(&cleaned_runtime.join("_work").join("late-child"))
7837        else {
7838            terminal_block.release();
7839            eprintln!("skipped: this account cannot inject the two independent deletion refusals");
7840            return;
7841        };
7842
7843        let restarted = harness.restart();
7844        for _ in 0..2 {
7845            restarted
7846                .recover_startup(std::slice::from_ref(&harness.policy))
7847                .await
7848                .expect("repeated recovery remains ready while both residues are locked");
7849        }
7850        assert_ne!(
7851            harness.attempt(terminal.id).state(),
7852            AttemptState::Cleaned,
7853            "the journal does not claim the terminal workspace was removed"
7854        );
7855        assert_eq!(
7856            harness.attempt(cleaned.id).state(),
7857            AttemptState::Cleaned,
7858            "late residue does not undo an already durable cleaned transition"
7859        );
7860        assert!(terminal_runtime.exists());
7861        assert!(cleaned_runtime.exists());
7862        assert_eq!(
7863            harness.packages.releases.load(Ordering::SeqCst),
7864            1,
7865            "repeated recovery neither loses nor duplicates package leases"
7866        );
7867
7868        terminal_block.release();
7869        cleaned_block.release();
7870        // A tolerated cleanup failure is retried after its backoff delay.
7871        harness.clock.advance_secs(31);
7872        restarted
7873            .supervise(&harness.policy)
7874            .await
7875            .expect("one ordinary pass clears both residues after their locks disappear");
7876        assert!(!terminal_runtime.exists());
7877        assert!(!cleaned_runtime.exists());
7878        assert_eq!(harness.attempt(terminal.id).state(), AttemptState::Cleaned);
7879        assert_eq!(harness.attempt(cleaned.id).state(), AttemptState::Cleaned);
7880        assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 2);
7881    }
7882
7883    #[tokio::test]
7884    async fn a_terminal_attempt_whose_runtime_is_already_gone_recovers_cleanly() {
7885        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7886        harness.ready().await;
7887        let attempt = harness.launch().await;
7888        let runtime = attempt.runtime_path().to_path_buf();
7889        harness.conclude(attempt.id);
7890        fs::remove_dir_all(&runtime).unwrap();
7891
7892        harness
7893            .restart()
7894            .recover_startup(std::slice::from_ref(&harness.policy))
7895            .await
7896            .expect("an already absent disposable runtime is successful cleanup");
7897
7898        assert_eq!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
7899        assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 1);
7900    }
7901
7902    #[tokio::test]
7903    async fn a_non_workspace_cleanup_failure_still_fails_closed_and_can_retry() {
7904        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7905        harness.ready().await;
7906        let attempt = harness.launch().await;
7907        let runtime = attempt.runtime_path().to_path_buf();
7908        harness.conclude(attempt.id);
7909        harness.packages.fail_releases(1);
7910
7911        let restarted = harness.restart();
7912        let failure = restarted
7913            .recover_startup(std::slice::from_ref(&harness.policy))
7914            .await
7915            .expect_err("a package-accounting failure is not safe to downgrade to residue");
7916        assert!(
7917            failure
7918                .reason()
7919                .to_string()
7920                .contains("package lease could not be released")
7921        );
7922        assert!(
7923            !runtime.exists(),
7924            "workspace removal completed before release failed"
7925        );
7926        assert_ne!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
7927
7928        restarted
7929            .recover_startup(std::slice::from_ref(&harness.policy))
7930            .await
7931            .expect("the same journal entry retries safely after the transient failure");
7932        assert_eq!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
7933        assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 1);
7934    }
7935
7936    #[tokio::test]
7937    async fn startup_never_scrubs_a_cleaned_persistent_work_directory() {
7938        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
7939            .with_persistent_workspace(1);
7940        harness.ready().await;
7941        let attempt = harness.launch().await;
7942        let retained = attempt.runtime_path().join("_work").join("checkout-marker");
7943        fs::create_dir_all(retained.parent().unwrap()).unwrap();
7944        fs::write(&retained, b"persistent checkout").unwrap();
7945        harness.cleanup_retaining_work(attempt.id).await;
7946
7947        harness
7948            .restart()
7949            .recover_startup(std::slice::from_ref(&harness.policy))
7950            .await
7951            .expect("a cleaned persistent slot is already reconciled");
7952
7953        assert_eq!(fs::read(&retained).unwrap(), b"persistent checkout");
7954        assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 1);
7955    }
7956
7957    #[tokio::test]
7958    async fn an_idle_exit_is_not_a_failure_in_the_journal_or_events() {
7959        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7960        harness.ready().await;
7961        let started = harness.launch().await;
7962        harness
7963            .github
7964            .observe(GithubRunnerObservation::Registered { busy: false });
7965        harness.launcher.supervise(&harness.policy).await.unwrap();
7966        harness.clock.advance_secs(11);
7967        harness.processes.set_alive(false);
7968        harness
7969            .github
7970            .observe(GithubRunnerObservation::NotRegistered);
7971        harness.launcher.supervise(&harness.policy).await.unwrap();
7972
7973        let cleaned = harness.only_attempt();
7974        assert!(cleaned.outcome().unwrap().is_idle_exit());
7975        assert!(!cleaned.outcome().unwrap().is_failure());
7976        assert!(!started.runtime_path().exists());
7977        assert!(
7978            harness
7979                .reconcile_events
7980                .events()
7981                .iter()
7982                .any(|event| matches!(
7983                    event,
7984                    LifecycleEvent::AttemptCleaned {
7985                        outcome: OutcomeKind::IdleExit,
7986                        ..
7987                    }
7988                ))
7989        );
7990        assert!(!harness.events.events().iter().any(|event| matches!(
7991            event,
7992            AttemptEvent::Concluded {
7993                outcome: OutcomeKind::Failed,
7994                ..
7995            }
7996        )));
7997    }
7998
7999    #[tokio::test]
8000    async fn handoff_is_absent_after_success_and_every_failed_spawn_retry() {
8001        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8002        harness.processes.fail_spawns(2);
8003        harness.ready().await;
8004        let attempt = harness.launch().await;
8005        assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 3);
8006        assert!(harness.processes.saw_secret.load(Ordering::SeqCst));
8007        let names: Vec<_> = fs::read_dir(attempt.runtime_path())
8008            .unwrap()
8009            .map(|entry| entry.unwrap().file_name())
8010            .collect();
8011        assert!(
8012            names.iter().all(|name| {
8013                !name
8014                    .to_string_lossy()
8015                    .starts_with(RestrictiveHandoff::NAME_PREFIX)
8016            }),
8017            "JIT artifact survived: {names:?}"
8018        );
8019        assert_eq!(
8020            *harness.delay.0.lock().unwrap(),
8021            vec![Duration::from_millis(10), Duration::from_millis(20)]
8022        );
8023    }
8024
8025    #[tokio::test]
8026    async fn jit_retry_stops_with_demand_and_a_terminal_403_never_retries() {
8027        let gone = Harness::new(
8028            FakeGithubLifecycle::default().fail(false),
8029            Arc::new(FakeDemand::answering([false])),
8030        );
8031        gone.ready().await;
8032        assert!(gone.launch_result().await.is_err());
8033        assert_eq!(gone.github.registrations.load(Ordering::SeqCst), 1);
8034        assert!(gone.delay.0.lock().unwrap().is_empty());
8035
8036        let forbidden = Harness::new(
8037            FakeGithubLifecycle::default().fail(true),
8038            Arc::new(PersistentDemand),
8039        );
8040        forbidden.ready().await;
8041        assert!(forbidden.launch_result().await.is_err());
8042        assert_eq!(forbidden.github.registrations.load(Ordering::SeqCst), 1);
8043        assert!(forbidden.delay.0.lock().unwrap().is_empty());
8044        assert!(matches!(
8045            forbidden.only_attempt().outcome(),
8046            Some(AttemptOutcome::Failed {
8047                reason: FailureReason::Other(action)
8048            }) if action.contains("403")
8049        ));
8050
8051        let transient = Harness::new(
8052            FakeGithubLifecycle::default().fail(false).fail(false),
8053            Arc::new(PersistentDemand),
8054        );
8055        transient.ready().await;
8056        transient.launch().await;
8057        assert_eq!(transient.github.registrations.load(Ordering::SeqCst), 3);
8058        assert_eq!(
8059            *transient.delay.0.lock().unwrap(),
8060            vec![Duration::from_millis(10), Duration::from_millis(20)]
8061        );
8062    }
8063
8064    /// The layout has to leave room for what the runner writes underneath it.
8065    ///
8066    /// Windows refuses a path over `MAX_PATH`, and this product's own CI hit
8067    /// that: 264 characters against a limit of 260, failing three checkout
8068    /// retries with `Filename too long`. The two identifiers in the old layout
8069    /// cost 74 characters between them for no benefit -- an attempt id is
8070    /// unique on its own.
8071    #[test]
8072    fn a_workspace_leaves_room_for_the_deepest_path_a_checkout_writes() {
8073        const MAX_PATH: usize = 260;
8074        // The real root on the machine this was found on.
8075        let root = r"C:\Users\IvanD\AppData\Local\IvanMurzak\runner-manager\data\runtime";
8076        // What `actions/checkout` writes at its deepest: the work directory,
8077        // the repository named twice, and a pack keep-file with a 40-character
8078        // object name.
8079        let repo = "GitHub-Runner-Scaler-UI";
8080        let deepest = format!(
8081            r"_work\{repo}\{repo}\.git\objects\pack\pack-{}.keep",
8082            "0".repeat(40)
8083        );
8084
8085        let name = workspace_name(AttemptId::new_random());
8086        assert_eq!(name.len(), WORKSPACE_NAME_LEN, "{name}");
8087        assert!(
8088            name.chars().all(|c| c.is_ascii_hexdigit()),
8089            "a directory name must not carry the identifier's dashes: {name}"
8090        );
8091
8092        let full = format!(r"{root}\{name}\{deepest}");
8093        assert!(
8094            full.len() < MAX_PATH,
8095            "the deepest path a checkout writes must fit: {} characters, limit {MAX_PATH}",
8096            full.len()
8097        );
8098
8099        // The discriminator: the layout this replaced does not fit, so a test
8100        // that passed for both would be proving nothing.
8101        let old = format!(
8102            r"{root}\{}\{}\{deepest}",
8103            PolicyId::new_random(),
8104            AttemptId::new_random()
8105        );
8106        assert!(
8107            old.len() > MAX_PATH,
8108            "the old layout is supposed to be the thing that did not fit: {} characters",
8109            old.len()
8110        );
8111    }
8112
8113    #[tokio::test]
8114    async fn two_attempts_never_share_a_workspace_even_after_failure() {
8115        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8116        harness.ready().await;
8117        let first = harness.launch().await;
8118        fs::write(first.runtime_path().join("hostile-leftover"), b"first job").unwrap();
8119        harness
8120            .github
8121            .observe(GithubRunnerObservation::Registered { busy: false });
8122        harness.launcher.supervise(&harness.policy).await.unwrap();
8123        harness.clock.advance_secs(11);
8124        harness.processes.set_alive(false);
8125        harness
8126            .github
8127            .observe(GithubRunnerObservation::NotRegistered);
8128        harness.launcher.supervise(&harness.policy).await.unwrap();
8129        assert!(!first.runtime_path().exists());
8130
8131        let second = harness.launch().await;
8132        assert_ne!(first.runtime_path(), second.runtime_path());
8133        assert!(!second.runtime_path().join("hostile-leftover").exists());
8134
8135        fs::write(
8136            second.runtime_path().join("hostile-on-failure"),
8137            b"second job",
8138        )
8139        .unwrap();
8140        harness.processes.set_alive(false);
8141        harness
8142            .github
8143            .observe(GithubRunnerObservation::NotRegistered);
8144        harness.launcher.supervise(&harness.policy).await.unwrap();
8145        assert!(
8146            !second.runtime_path().exists(),
8147            "failed workspace was retained"
8148        );
8149    }
8150
8151    #[tokio::test]
8152    async fn a_runner_that_never_gets_a_job_is_stopped_deregistered_and_not_replaced() {
8153        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8154        harness.ready().await;
8155        let attempt = harness.launch().await;
8156
8157        // Registered and waiting, which is where it stays: the fake keeps
8158        // answering the same observation, exactly as GitHub does for a runner
8159        // nobody assigns work to.
8160        harness
8161            .github
8162            .observe(GithubRunnerObservation::Registered { busy: false });
8163        harness.launcher.supervise(&harness.policy).await.unwrap();
8164        assert_eq!(harness.only_attempt().state(), AttemptState::Idle);
8165
8166        // One second inside the ten-second idle timeout nothing happens, which
8167        // is what keeps this from being a test that would pass on any clock.
8168        harness.clock.advance_secs(9);
8169        harness
8170            .github
8171            .observe(GithubRunnerObservation::Registered { busy: false });
8172        let none_yet = harness.launcher.supervise(&harness.policy).await.unwrap();
8173        assert_eq!(harness.only_attempt().state(), AttemptState::Idle);
8174        assert!(none_yet.is_empty());
8175        assert_eq!(harness.processes.terminations.load(Ordering::SeqCst), 0);
8176
8177        // Past it, the agent ends the runner itself.
8178        harness.clock.advance_secs(1);
8179        harness
8180            .github
8181            .observe(GithubRunnerObservation::Registered { busy: false });
8182        let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
8183
8184        let concluded = harness.store.attempt(attempt.id).unwrap().unwrap();
8185        assert_eq!(
8186            concluded.outcome(),
8187            Some(&AttemptOutcome::ExitedIdleWithoutWork),
8188            "a surplus runner did not fail; recording one as a failure sends an operator \
8189             hunting a fault that does not exist"
8190        );
8191        assert_eq!(concluded.state(), AttemptState::Cleaned);
8192        assert_eq!(harness.processes.terminations.load(Ordering::SeqCst), 1);
8193        assert!(!attempt.runtime_path().exists());
8194
8195        // The registration goes with it. Without this the runner stays listed
8196        // in the target's runner settings after the process it named is gone.
8197        assert_eq!(
8198            *harness.github.deregistrations.lock().unwrap(),
8199            vec![73],
8200            "the attempt's own runner id, deleted exactly once"
8201        );
8202
8203        // And nothing is started in its place: the work it was launched for went
8204        // elsewhere, so a replacement would rebuild it every idle timeout.
8205        assert!(
8206            replacements.is_empty(),
8207            "a surplus exit must not request a replacement"
8208        );
8209    }
8210
8211    #[tokio::test]
8212    async fn a_registration_github_will_not_delete_still_concludes_the_attempt() {
8213        // The delete is best-effort by construction: the process is gone and the
8214        // slot has to come back. Holding the conclusion until GitHub cooperates
8215        // would leak a capacity slot on every unreachable moment.
8216        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8217        harness.ready().await;
8218        let attempt = harness.launch().await;
8219        harness
8220            .github
8221            .observe(GithubRunnerObservation::Registered { busy: false });
8222        harness.launcher.supervise(&harness.policy).await.unwrap();
8223
8224        harness
8225            .github
8226            .deregistration_fails
8227            .store(true, Ordering::SeqCst);
8228        harness.clock.advance_secs(11);
8229        harness
8230            .github
8231            .observe(GithubRunnerObservation::Registered { busy: false });
8232        harness.launcher.supervise(&harness.policy).await.unwrap();
8233
8234        assert_eq!(
8235            *harness.github.deregistrations.lock().unwrap(),
8236            vec![73],
8237            "the delete was attempted"
8238        );
8239        let concluded = harness.store.attempt(attempt.id).unwrap().unwrap();
8240        assert_eq!(
8241            concluded.outcome(),
8242            Some(&AttemptOutcome::ExitedIdleWithoutWork),
8243            "the attempt concluded anyway"
8244        );
8245        assert_eq!(concluded.state(), AttemptState::Cleaned);
8246        assert!(!attempt.runtime_path().exists());
8247    }
8248
8249    #[tokio::test]
8250    async fn exit_before_acceptance_returns_replacement_intent_without_launching() {
8251        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8252        harness.ready().await;
8253        let first = harness.launch().await;
8254        harness.processes.set_alive(false);
8255        harness
8256            .github
8257            .observe(GithubRunnerObservation::NotRegistered);
8258        let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
8259        let failed = harness.store.attempt(first.id).unwrap().unwrap();
8260        assert!(matches!(
8261            failed.outcome(),
8262            Some(AttemptOutcome::Failed {
8263                reason: FailureReason::ProcessExitedUnexpectedly
8264            })
8265        ));
8266        assert!(!first.runtime_path().exists());
8267
8268        assert_eq!(
8269            replacements,
8270            vec![ReplacementIntent {
8271                policy: harness.policy.id,
8272                previous_attempt: first.id,
8273                operation: "exit_before_acceptance_replacement",
8274            }]
8275        );
8276        assert_eq!(harness.store.attempts().unwrap().len(), 1);
8277        assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 1);
8278        assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 1);
8279        assert!(harness.delay.0.lock().unwrap().is_empty());
8280    }
8281
8282    /// Writes the runner's own diagnostics: always the listener's log, and the
8283    /// job worker's when `ran_a_job`. `Runner.Listener` starts `Runner.Worker`
8284    /// only for a job it has acquired.
8285    fn write_runner_diagnostics(runtime: &Path, ran_a_job: bool) {
8286        let diag = runtime.join("_diag");
8287        fs::create_dir_all(&diag).unwrap();
8288        fs::write(diag.join("Runner_20261007-211941-utc.log"), b"listener").unwrap();
8289        if ran_a_job {
8290            fs::write(diag.join("Worker_20261007-211950-utc.log"), b"worker").unwrap();
8291        }
8292    }
8293
8294    /// The macOS field case: a job of a few seconds starts and ends between two
8295    /// passes, so GitHub was never seen reporting the runner busy -- the attempt
8296    /// is still `starting` -- and the registration is already gone. It used to be
8297    /// recorded `process_exited_unexpectedly`, with a replacement runner.
8298    #[tokio::test]
8299    async fn a_job_too_short_to_be_seen_busy_is_recorded_as_completed() {
8300        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8301        harness.ready().await;
8302        let started = harness.launch().await;
8303        assert_eq!(started.state(), AttemptState::Starting);
8304        write_runner_diagnostics(started.runtime_path(), true);
8305        harness.processes.finish_successfully();
8306        harness
8307            .github
8308            .observe(GithubRunnerObservation::NotRegistered);
8309
8310        let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
8311
8312        let cleaned = harness.only_attempt();
8313        assert_eq!(cleaned.outcome(), Some(&AttemptOutcome::CompletedJob));
8314        assert_eq!(cleaned.state(), AttemptState::Cleaned);
8315        assert_eq!(cleaned.github_runner_id(), Some(73));
8316        assert!(
8317            replacements.is_empty(),
8318            "a finished job needs no replacement"
8319        );
8320        let busy_seen = harness.events.events().into_iter().any(|event| {
8321            matches!(
8322                event,
8323                AttemptEvent::State {
8324                    state: AttemptState::Busy,
8325                    ..
8326                }
8327            )
8328        });
8329        assert!(busy_seen, "the journal walks the diagram to `busy` first");
8330    }
8331
8332    /// The same from `idle`: seen registered and waiting once, then gone.
8333    #[tokio::test]
8334    async fn a_job_taken_between_two_passes_after_idle_is_recorded_as_completed() {
8335        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8336        harness.ready().await;
8337        let started = harness.launch().await;
8338        harness
8339            .github
8340            .observe(GithubRunnerObservation::Registered { busy: false });
8341        harness.launcher.supervise(&harness.policy).await.unwrap();
8342        assert_eq!(harness.only_attempt().state(), AttemptState::Idle);
8343
8344        write_runner_diagnostics(started.runtime_path(), true);
8345        harness.processes.finish_successfully();
8346        harness
8347            .github
8348            .observe(GithubRunnerObservation::NotRegistered);
8349        let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
8350
8351        assert_eq!(
8352            harness.only_attempt().outcome(),
8353            Some(&AttemptOutcome::CompletedJob)
8354        );
8355        assert!(replacements.is_empty());
8356    }
8357
8358    /// The reverse error, from the same sampling: GitHub showed the runner
8359    /// assigned, the runner never started the job (no worker log), and it exited
8360    /// 0. That is not a completed job.
8361    #[tokio::test]
8362    async fn an_assignment_the_runner_never_took_up_is_not_a_completed_job() {
8363        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8364        harness.ready().await;
8365        let started = harness.launch().await;
8366        harness
8367            .github
8368            .observe(GithubRunnerObservation::Registered { busy: true });
8369        harness.launcher.supervise(&harness.policy).await.unwrap();
8370        assert_eq!(harness.only_attempt().state(), AttemptState::Busy);
8371
8372        write_runner_diagnostics(started.runtime_path(), false);
8373        harness.processes.finish_successfully();
8374        harness
8375            .github
8376            .observe(GithubRunnerObservation::NotRegistered);
8377        harness.launcher.supervise(&harness.policy).await.unwrap();
8378
8379        let cleaned = harness.only_attempt();
8380        assert_eq!(
8381            cleaned.outcome(),
8382            Some(&AttemptOutcome::ExitedIdleWithoutWork)
8383        );
8384        assert_eq!(cleaned.state(), AttemptState::Cleaned);
8385    }
8386
8387    /// The mirror case: the runner really ran its job and exited 0 before GitHub
8388    /// took it off the list. It used to be recorded `orphaned`.
8389    #[tokio::test]
8390    async fn a_finished_job_still_listed_at_github_is_completed_and_deregistered() {
8391        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8392        harness.ready().await;
8393        let started = harness.launch().await;
8394        harness
8395            .github
8396            .observe(GithubRunnerObservation::Registered { busy: true });
8397        harness.launcher.supervise(&harness.policy).await.unwrap();
8398
8399        write_runner_diagnostics(started.runtime_path(), true);
8400        harness.processes.finish_successfully();
8401        harness
8402            .github
8403            .observe(GithubRunnerObservation::Registered { busy: true });
8404        harness.launcher.supervise(&harness.policy).await.unwrap();
8405
8406        assert_eq!(
8407            harness.only_attempt().outcome(),
8408            Some(&AttemptOutcome::CompletedJob)
8409        );
8410        assert_eq!(*harness.github.deregistrations.lock().unwrap(), vec![73]);
8411    }
8412
8413    /// A live runner journalled `busy` whose assignment GitHub withdrew goes
8414    /// back to `idle`, where the idle timeout holds it again.
8415    #[tokio::test]
8416    async fn a_live_runner_whose_assignment_was_withdrawn_goes_back_to_idle() {
8417        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8418        harness.ready().await;
8419        let started = harness.launch().await;
8420        harness
8421            .github
8422            .observe(GithubRunnerObservation::Registered { busy: true });
8423        harness.launcher.supervise(&harness.policy).await.unwrap();
8424        assert_eq!(harness.only_attempt().state(), AttemptState::Busy);
8425
8426        write_runner_diagnostics(started.runtime_path(), false);
8427        harness
8428            .github
8429            .observe(GithubRunnerObservation::Registered { busy: false });
8430        harness.launcher.supervise(&harness.policy).await.unwrap();
8431        assert_eq!(harness.only_attempt().state(), AttemptState::Idle);
8432
8433        // A runner that did start its job keeps `busy` whatever GitHub says.
8434        harness
8435            .github
8436            .observe(GithubRunnerObservation::Registered { busy: true });
8437        harness.launcher.supervise(&harness.policy).await.unwrap();
8438        write_runner_diagnostics(started.runtime_path(), true);
8439        harness
8440            .github
8441            .observe(GithubRunnerObservation::Registered { busy: false });
8442        harness.launcher.supervise(&harness.policy).await.unwrap();
8443        assert_eq!(harness.only_attempt().state(), AttemptState::Busy);
8444    }
8445
8446    /// While GitHub cannot be reached nothing is concluded, so a registration
8447    /// still listed there is removed once it answers.
8448    #[tokio::test]
8449    async fn a_clean_exit_waits_for_github_to_answer() {
8450        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8451        harness.ready().await;
8452        let started = harness.launch().await;
8453        write_runner_diagnostics(started.runtime_path(), true);
8454        harness.processes.finish_successfully();
8455        harness.github.observe(GithubRunnerObservation::Unreachable);
8456        harness.launcher.supervise(&harness.policy).await.unwrap();
8457        assert_eq!(harness.only_attempt().state(), AttemptState::Starting);
8458
8459        harness
8460            .github
8461            .observe(GithubRunnerObservation::NotRegistered);
8462        harness.launcher.supervise(&harness.policy).await.unwrap();
8463        assert_eq!(
8464            harness.only_attempt().outcome(),
8465            Some(&AttemptOutcome::CompletedJob)
8466        );
8467    }
8468
8469    /// A runner id nobody can name is not a reason to stop supervising the
8470    /// policy: the attempt is concluded the way it was before, without one.
8471    #[tokio::test]
8472    async fn a_short_job_with_no_runner_id_still_concludes() {
8473        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8474        harness.ready().await;
8475        let started = harness.launch().await;
8476        fs::remove_file(started.runtime_path().join(RUNNER_ID_FILE)).unwrap();
8477        write_runner_diagnostics(started.runtime_path(), true);
8478        harness.processes.finish_successfully();
8479        harness
8480            .github
8481            .observe(GithubRunnerObservation::NotRegistered);
8482        harness
8483            .launcher
8484            .supervise(&harness.policy)
8485            .await
8486            .expect("supervision goes on");
8487        assert!(harness.only_attempt().is_terminal());
8488    }
8489
8490    /// Without the runner's diagnostics nothing changes: a clean exit seen only
8491    /// at `starting` is still a runner that exited before taking a job.
8492    #[tokio::test]
8493    async fn without_runner_diagnostics_an_early_exit_is_still_a_failure() {
8494        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8495        harness.ready().await;
8496        harness.launch().await;
8497        harness.processes.finish_successfully();
8498        harness
8499            .github
8500            .observe(GithubRunnerObservation::NotRegistered);
8501        harness.launcher.supervise(&harness.policy).await.unwrap();
8502        assert!(matches!(
8503            harness.only_attempt().outcome(),
8504            Some(AttemptOutcome::Failed {
8505                reason: FailureReason::ProcessExitedUnexpectedly
8506            })
8507        ));
8508    }
8509
8510    #[tokio::test]
8511    async fn expired_jit_is_removed_and_does_not_reregister_after_demand_disappears() {
8512        let harness = Harness::new(
8513            FakeGithubLifecycle::default(),
8514            Arc::new(FakeDemand::answering([false])),
8515        );
8516        let id = AttemptId::new_random();
8517        let runtime = harness
8518            .launcher
8519            .app_paths
8520            .runtime_dir()
8521            .join(harness.policy.id.to_string())
8522            .join(id.to_string());
8523        fs::create_dir_all(&runtime).unwrap();
8524        let mut attempt =
8525            RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
8526        attempt.jit_received(harness.clock.now()).unwrap();
8527        harness.store.record_attempt(&attempt).unwrap();
8528        harness.clock.advance_secs(11);
8529        let replacements = harness
8530            .launcher
8531            .recover_startup(std::slice::from_ref(&harness.policy))
8532            .await
8533            .unwrap();
8534        assert_eq!(
8535            replacements,
8536            vec![ReplacementIntent {
8537                policy: harness.policy.id,
8538                previous_attempt: id,
8539                operation: "jit_expired_replacement",
8540            }]
8541        );
8542
8543        let cleaned = harness.store.attempt(id).unwrap().unwrap();
8544        assert_eq!(cleaned.state(), AttemptState::Cleaned);
8545        assert!(matches!(
8546            cleaned.outcome(),
8547            Some(AttemptOutcome::Failed {
8548                reason: FailureReason::JitExpired
8549            })
8550        ));
8551        assert!(!runtime.exists());
8552        assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
8553        assert!(harness.delay.0.lock().unwrap().is_empty());
8554    }
8555
8556    #[tokio::test]
8557    async fn expired_jit_returns_intent_but_never_launches_inside_lifecycle() {
8558        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8559        let id = AttemptId::new_random();
8560        let runtime = harness
8561            .launcher
8562            .app_paths
8563            .runtime_dir()
8564            .join("expired-with-demand");
8565        fs::create_dir_all(&runtime).unwrap();
8566        let mut attempt =
8567            RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
8568        attempt.jit_received(harness.clock.now()).unwrap();
8569        harness.store.record_attempt(&attempt).unwrap();
8570        harness.clock.advance_secs(11);
8571        let replacements = harness
8572            .launcher
8573            .recover_startup(std::slice::from_ref(&harness.policy))
8574            .await
8575            .unwrap();
8576
8577        let attempts = harness.store.attempts().unwrap();
8578        assert_eq!(attempts.len(), 1);
8579        assert_eq!(
8580            attempts
8581                .iter()
8582                .find(|attempt| attempt.id == id)
8583                .unwrap()
8584                .state(),
8585            AttemptState::Cleaned
8586        );
8587        assert_eq!(
8588            replacements,
8589            vec![ReplacementIntent {
8590                policy: harness.policy.id,
8591                previous_attempt: id,
8592                operation: "jit_expired_replacement",
8593            }]
8594        );
8595        assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
8596        assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 0);
8597        assert!(harness.delay.0.lock().unwrap().is_empty());
8598    }
8599
8600    #[tokio::test]
8601    async fn package_materialization_retries_are_bounded_and_demand_adjacent() {
8602        let persistent = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8603        persistent.packages.fail_materializations(2);
8604        persistent.ready().await;
8605        persistent.launch().await;
8606        assert_eq!(
8607            persistent.packages.materializations.load(Ordering::SeqCst),
8608            3
8609        );
8610        assert_eq!(
8611            *persistent.delay.0.lock().unwrap(),
8612            vec![Duration::from_millis(10), Duration::from_millis(20)]
8613        );
8614
8615        let gone_before_wait = Harness::new(
8616            FakeGithubLifecycle::default(),
8617            Arc::new(FakeDemand::answering([false])),
8618        );
8619        gone_before_wait.packages.fail_materializations(3);
8620        gone_before_wait.ready().await;
8621        assert!(gone_before_wait.launch_result().await.is_err());
8622        assert_eq!(
8623            gone_before_wait
8624                .packages
8625                .materializations
8626                .load(Ordering::SeqCst),
8627            1
8628        );
8629        assert!(gone_before_wait.delay.0.lock().unwrap().is_empty());
8630
8631        let gone_during_wait = Harness::new(
8632            FakeGithubLifecycle::default(),
8633            Arc::new(FakeDemand::answering([true, false])),
8634        );
8635        gone_during_wait.packages.fail_materializations(3);
8636        gone_during_wait.ready().await;
8637        assert!(gone_during_wait.launch_result().await.is_err());
8638        assert_eq!(
8639            gone_during_wait
8640                .packages
8641                .materializations
8642                .load(Ordering::SeqCst),
8643            1
8644        );
8645        assert_eq!(
8646            *gone_during_wait.delay.0.lock().unwrap(),
8647            vec![Duration::from_millis(10)]
8648        );
8649    }
8650
8651    #[tokio::test]
8652    async fn replacement_is_intent_only_and_never_launches_inside_lifecycle() {
8653        let harness = Harness::new(
8654            FakeGithubLifecycle::default(),
8655            Arc::new(FakeDemand::answering([true, false])),
8656        );
8657        harness.ready().await;
8658        let first = harness.launch().await;
8659        harness.processes.set_alive(false);
8660        harness
8661            .github
8662            .observe(GithubRunnerObservation::NotRegistered);
8663        let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
8664
8665        assert_eq!(harness.store.attempts().unwrap().len(), 1);
8666        assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 1);
8667        assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 1);
8668        assert!(harness.delay.0.lock().unwrap().is_empty());
8669        assert_eq!(
8670            replacements,
8671            vec![ReplacementIntent {
8672                policy: harness.policy.id,
8673                previous_attempt: first.id,
8674                operation: "exit_before_acceptance_replacement",
8675            }]
8676        );
8677        assert_eq!(
8678            harness.store.attempt(first.id).unwrap().unwrap().state(),
8679            AttemptState::Cleaned
8680        );
8681    }
8682
8683    #[tokio::test]
8684    async fn startup_adopts_a_live_process_and_refuses_launch_before_recovery() {
8685        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8686        let before = harness.launch_result().await;
8687        assert!(before.is_err());
8688        assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 0);
8689
8690        let id = AttemptId::new_random();
8691        let runtime = harness.launcher.app_paths.runtime_dir().join("adopt");
8692        fs::create_dir_all(&runtime).unwrap();
8693        let mut attempt =
8694            RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
8695        attempt.jit_received(harness.clock.now()).unwrap();
8696        attempt.started(4242, harness.clock.now()).unwrap();
8697        harness.store.record_attempt(&attempt).unwrap();
8698        harness.processes.set_alive(true);
8699        harness
8700            .github
8701            .observe(GithubRunnerObservation::NotRegistered);
8702        let replacements = harness
8703            .launcher
8704            .recover_startup(std::slice::from_ref(&harness.policy))
8705            .await
8706            .unwrap();
8707        assert!(replacements.is_empty());
8708        assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 0);
8709        assert!(
8710            harness
8711                .events
8712                .events()
8713                .contains(&AttemptEvent::Adopted { attempt: id })
8714        );
8715    }
8716
8717    #[tokio::test]
8718    async fn spawn_before_starting_crash_recovers_pid_then_completes_and_cleans() {
8719        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8720        let id = AttemptId::new_random();
8721        let runtime = harness
8722            .launcher
8723            .app_paths
8724            .runtime_dir()
8725            .join("spawn-before-starting");
8726        fs::create_dir_all(&runtime).unwrap();
8727        let mut attempt =
8728            RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
8729        attempt.jit_received(harness.clock.now()).unwrap();
8730        harness.store.record_attempt(&attempt).unwrap();
8731        harness.processes.set_alive(true);
8732        harness
8733            .github
8734            .observe(GithubRunnerObservation::Registered { busy: true });
8735
8736        let replacements = harness
8737            .launcher
8738            .recover_startup(std::slice::from_ref(&harness.policy))
8739            .await
8740            .unwrap();
8741        assert!(replacements.is_empty());
8742        let recovered = harness.store.attempt(id).unwrap().unwrap();
8743        assert_eq!(recovered.state(), AttemptState::Busy);
8744        assert_eq!(recovered.process_id(), Some(4242));
8745        assert_eq!(recovered.github_runner_id(), Some(73));
8746        let events = harness.events.events();
8747        let starting = events
8748            .iter()
8749            .position(|event| matches!(event, AttemptEvent::State { attempt, state: AttemptState::Starting } if *attempt == id))
8750            .unwrap();
8751        let busy = events
8752            .iter()
8753            .position(|event| matches!(event, AttemptEvent::State { attempt, state: AttemptState::Busy } if *attempt == id))
8754            .unwrap();
8755        assert!(starting < busy, "recovery skipped a legal edge: {events:?}");
8756
8757        harness.processes.finish_successfully();
8758        harness
8759            .github
8760            .observe(GithubRunnerObservation::NotRegistered);
8761        assert!(
8762            harness
8763                .launcher
8764                .supervise(&harness.policy)
8765                .await
8766                .unwrap()
8767                .is_empty()
8768        );
8769        let cleaned = harness.store.attempt(id).unwrap().unwrap();
8770        assert_eq!(cleaned.state(), AttemptState::Cleaned);
8771        assert_eq!(cleaned.outcome(), Some(&AttemptOutcome::CompletedJob));
8772        assert!(!runtime.exists());
8773    }
8774
8775    #[tokio::test]
8776    async fn failed_post_spawn_stop_keeps_capacity_until_supervision_proves_death() {
8777        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8778        harness.processes.fail_spawn_with_live_child();
8779        harness.ready().await;
8780        assert!(harness.launch_result().await.is_err());
8781
8782        let attempt = harness.only_attempt();
8783        assert_eq!(attempt.state(), AttemptState::Starting);
8784        assert_eq!(attempt.process_id(), Some(4242));
8785        assert!(attempt.outcome().is_none());
8786        assert!(attempt.state().counts_against_capacity());
8787        assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 1);
8788        assert!(harness.delay.0.lock().unwrap().is_empty());
8789
8790        harness.processes.set_alive(false);
8791        harness
8792            .github
8793            .observe(GithubRunnerObservation::NotRegistered);
8794        let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
8795        assert_eq!(replacements.len(), 1);
8796        assert_eq!(
8797            harness.store.attempt(attempt.id).unwrap().unwrap().state(),
8798            AttemptState::Cleaned
8799        );
8800    }
8801
8802    #[tokio::test]
8803    async fn remote_runner_identity_closes_both_sides_of_the_registration_crash_boundary() {
8804        for sidecar_already_present in [false, true] {
8805            let harness = Harness::new(
8806                FakeGithubLifecycle::default(),
8807                Arc::new(FakeDemand::answering([false])),
8808            );
8809            let id = AttemptId::new_random();
8810            let runtime =
8811                harness
8812                    .launcher
8813                    .app_paths
8814                    .runtime_dir()
8815                    .join(if sidecar_already_present {
8816                        "after-id-sidecar"
8817                    } else {
8818                        "before-id-sidecar"
8819                    });
8820            fs::create_dir_all(&runtime).unwrap();
8821            let mut attempt =
8822                RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
8823            if sidecar_already_present {
8824                write_runner_id(&runtime, 73).unwrap();
8825                attempt.jit_received(harness.clock.now()).unwrap();
8826            }
8827            harness.store.record_attempt(&attempt).unwrap();
8828            harness.processes.set_alive(true);
8829            harness
8830                .github
8831                .observe(GithubRunnerObservation::Registered { busy: false });
8832            harness
8833                .launcher
8834                .recover_startup(std::slice::from_ref(&harness.policy))
8835                .await
8836                .unwrap();
8837
8838            assert_eq!(read_runner_id(&runtime), Some(73));
8839            assert!(
8840                harness
8841                    .store
8842                    .attempt(id)
8843                    .unwrap()
8844                    .unwrap()
8845                    .outcome()
8846                    .is_none()
8847            );
8848            let events = harness.events.events();
8849            let recovered = events.iter().position(|event| {
8850                matches!(
8851                    event,
8852                    AttemptEvent::RemoteIdentityRecovered {
8853                        attempt,
8854                        runner_id: 73
8855                    } if *attempt == id
8856                )
8857            });
8858            assert_eq!(recovered.is_some(), !sidecar_already_present);
8859            if let Some(recovered) = recovered {
8860                let adopted = events
8861                    .iter()
8862                    .position(|event| matches!(event, AttemptEvent::Adopted { attempt } if *attempt == id))
8863                    .unwrap();
8864                assert!(
8865                    recovered < adopted,
8866                    "identity was not durable before adoption: {events:?}"
8867                );
8868            }
8869            assert!(runtime.exists());
8870        }
8871    }
8872
8873    #[tokio::test]
8874    async fn recovery_stays_closed_for_unknown_policy_and_unreachable_attempts() {
8875        let unknown = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8876        let unknown_attempt = RunnerAttempt::allocate(
8877            AttemptId::new_random(),
8878            PolicyId::from_u128(0xfeed),
8879            unknown
8880                .launcher
8881                .app_paths
8882                .runtime_dir()
8883                .join("unknown-policy"),
8884            unknown.clock.now(),
8885        );
8886        unknown.store.record_attempt(&unknown_attempt).unwrap();
8887        let expired_id = AttemptId::new_random();
8888        let expired_runtime = unknown
8889            .launcher
8890            .app_paths
8891            .runtime_dir()
8892            .join("expired-beside-unknown");
8893        fs::create_dir_all(&expired_runtime).unwrap();
8894        let mut expired = RunnerAttempt::allocate(
8895            expired_id,
8896            unknown.policy.id,
8897            expired_runtime,
8898            unknown.clock.now(),
8899        );
8900        expired.jit_received(unknown.clock.now()).unwrap();
8901        unknown.store.record_attempt(&expired).unwrap();
8902        unknown.clock.advance_secs(11);
8903        assert!(matches!(
8904            unknown
8905                .launcher
8906                .recover_startup(std::slice::from_ref(&unknown.policy))
8907                .await,
8908            Err(LifecycleError::RecoveryIncomplete)
8909        ));
8910        assert!(unknown.launch_result().await.is_err());
8911        assert_eq!(unknown.processes.spawns.load(Ordering::SeqCst), 0);
8912        let recovered_policy = fixtures::policy()
8913            .id(PolicyId::from_u128(0xfeed))
8914            .repository("octo/repo")
8915            .autoscale("home", 2)
8916            .active()
8917            .build();
8918        let pending = unknown
8919            .launcher
8920            .recover_startup(&[unknown.policy.clone(), recovered_policy])
8921            .await
8922            .unwrap();
8923        assert_eq!(
8924            pending,
8925            vec![ReplacementIntent {
8926                policy: unknown.policy.id,
8927                previous_attempt: expired_id,
8928                operation: "jit_expired_replacement",
8929            }]
8930        );
8931        assert_eq!(unknown.processes.spawns.load(Ordering::SeqCst), 0);
8932
8933        let unreachable = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8934        let id = AttemptId::new_random();
8935        let runtime = unreachable
8936            .launcher
8937            .app_paths
8938            .runtime_dir()
8939            .join("unreachable");
8940        fs::create_dir_all(&runtime).unwrap();
8941        unreachable
8942            .store
8943            .record_attempt(&RunnerAttempt::allocate(
8944                id,
8945                unreachable.policy.id,
8946                runtime,
8947                unreachable.clock.now(),
8948            ))
8949            .unwrap();
8950        unreachable
8951            .github
8952            .observe(GithubRunnerObservation::Unreachable);
8953        assert!(matches!(
8954            unreachable
8955                .launcher
8956                .recover_startup(std::slice::from_ref(&unreachable.policy))
8957                .await,
8958            Err(LifecycleError::RecoveryIncomplete)
8959        ));
8960        assert!(unreachable.launch_result().await.is_err());
8961        assert_eq!(unreachable.processes.spawns.load(Ordering::SeqCst), 0);
8962    }
8963
8964    #[tokio::test]
8965    async fn a_dead_busy_process_unknown_to_github_is_orphaned_and_cleaned() {
8966        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8967        let id = AttemptId::new_random();
8968        let runtime = harness.launcher.app_paths.runtime_dir().join("orphan");
8969        fs::create_dir_all(&runtime).unwrap();
8970        let mut attempt =
8971            RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
8972        attempt.jit_received(harness.clock.now()).unwrap();
8973        attempt.started(4242, harness.clock.now()).unwrap();
8974        attempt.assigned_job(73, harness.clock.now()).unwrap();
8975        harness.store.record_attempt(&attempt).unwrap();
8976        harness.processes.set_alive(false);
8977        harness
8978            .github
8979            .observe(GithubRunnerObservation::NotRegistered);
8980        harness
8981            .launcher
8982            .recover_startup(std::slice::from_ref(&harness.policy))
8983            .await
8984            .unwrap();
8985        let cleaned = harness.store.attempt(id).unwrap().unwrap();
8986        assert_eq!(cleaned.state(), AttemptState::Cleaned);
8987        assert_eq!(cleaned.outcome(), Some(&AttemptOutcome::Orphaned));
8988        assert!(!runtime.exists());
8989    }
8990
8991    #[tokio::test]
8992    async fn registration_timeout_journals_intent_stops_then_concludes_with_dead_reason() {
8993        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8994        harness.ready().await;
8995        let id = AttemptId::new_random();
8996        let runtime = harness.launcher.app_paths.runtime_dir().join("timeout");
8997        fs::create_dir_all(&runtime).unwrap();
8998        let mut attempt =
8999            RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
9000        attempt.jit_received(harness.clock.now()).unwrap();
9001        attempt.started(4242, harness.clock.now()).unwrap();
9002        harness.store.record_attempt(&attempt).unwrap();
9003        harness.clock.advance_secs(11);
9004        harness.processes.set_alive(true);
9005        harness
9006            .github
9007            .observe(GithubRunnerObservation::NotRegistered);
9008        let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
9009        assert_eq!(
9010            replacements,
9011            vec![ReplacementIntent {
9012                policy: harness.policy.id,
9013                previous_attempt: id,
9014                operation: "registration_timeout_replacement",
9015            }]
9016        );
9017
9018        assert_eq!(harness.processes.terminations.load(Ordering::SeqCst), 1);
9019        assert!(!harness.processes.alive.load(Ordering::SeqCst));
9020        let actions = harness.processes.actions.lock().unwrap().clone();
9021        let intent = actions
9022            .iter()
9023            .position(|action| *action == "terminate_intent")
9024            .unwrap();
9025        let signal = actions
9026            .iter()
9027            .position(|action| *action == "terminate")
9028            .unwrap();
9029        assert!(
9030            intent < signal,
9031            "intent was not durable before signal: {actions:?}"
9032        );
9033
9034        let cleaned = harness.store.attempt(id).unwrap().unwrap();
9035        assert!(matches!(
9036            cleaned.outcome(),
9037            Some(AttemptOutcome::Failed {
9038                reason: FailureReason::TerminatedAfterRegistrationTimeout
9039            })
9040        ));
9041        let events = harness.events.events();
9042        let intent = events
9043            .iter()
9044            .position(|event| matches!(event, AttemptEvent::TerminateIntent { .. }))
9045            .unwrap();
9046        let stopped = events
9047            .iter()
9048            .position(|event| matches!(event, AttemptEvent::Terminated { .. }))
9049            .unwrap();
9050        let concluded = events
9051            .iter()
9052            .position(|event| matches!(event, AttemptEvent::Concluded { .. }))
9053            .unwrap();
9054        assert!(intent < stopped && stopped < concluded, "{events:?}");
9055    }
9056
9057    #[tokio::test]
9058    async fn timeout_crash_recovery_returns_the_same_replacement_intent() {
9059        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
9060        let id = AttemptId::new_random();
9061        let runtime = harness
9062            .launcher
9063            .app_paths
9064            .runtime_dir()
9065            .join("timeout-after-crash");
9066        fs::create_dir_all(&runtime).unwrap();
9067        let mut attempt =
9068            RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
9069        attempt.jit_received(harness.clock.now()).unwrap();
9070        attempt.started(4242, harness.clock.now()).unwrap();
9071        harness.store.record_attempt(&attempt).unwrap();
9072        harness.processes.intent.store(true, Ordering::SeqCst);
9073        harness.processes.set_alive(false);
9074        harness
9075            .github
9076            .observe(GithubRunnerObservation::NotRegistered);
9077
9078        let replacements = harness
9079            .launcher
9080            .recover_startup(std::slice::from_ref(&harness.policy))
9081            .await
9082            .unwrap();
9083        assert_eq!(
9084            replacements,
9085            vec![ReplacementIntent {
9086                policy: harness.policy.id,
9087                previous_attempt: id,
9088                operation: "registration_timeout_replacement",
9089            }]
9090        );
9091        let consumed = RunnerLauncher::supervise(&harness.launcher, &harness.policy)
9092            .await
9093            .unwrap();
9094        assert_eq!(consumed, replacements);
9095        assert!(
9096            RunnerLauncher::supervise(&harness.launcher, &harness.policy)
9097                .await
9098                .unwrap()
9099                .is_empty(),
9100            "startup replacement evidence must be consumed exactly once by e1"
9101        );
9102        assert!(matches!(
9103            harness.store.attempt(id).unwrap().unwrap().outcome(),
9104            Some(AttemptOutcome::Failed {
9105                reason: FailureReason::TerminatedAfterRegistrationTimeout
9106            })
9107        ));
9108    }
9109
9110    #[tokio::test]
9111    async fn terminate_intent_sync_failure_prevents_signal_and_conclusion() {
9112        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
9113        let id = AttemptId::new_random();
9114        let runtime = harness
9115            .launcher
9116            .app_paths
9117            .runtime_dir()
9118            .join("timeout-sync-failure");
9119        fs::create_dir_all(&runtime).unwrap();
9120        let mut attempt =
9121            RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
9122        attempt.jit_received(harness.clock.now()).unwrap();
9123        attempt.started(4242, harness.clock.now()).unwrap();
9124        harness.store.record_attempt(&attempt).unwrap();
9125        harness.clock.advance_secs(11);
9126        harness.processes.set_alive(true);
9127        harness.processes.fail_intent();
9128        harness
9129            .github
9130            .observe(GithubRunnerObservation::NotRegistered);
9131
9132        assert!(
9133            harness
9134                .launcher
9135                .recover_startup(std::slice::from_ref(&harness.policy))
9136                .await
9137                .is_err()
9138        );
9139        assert_eq!(harness.processes.terminations.load(Ordering::SeqCst), 0);
9140        assert!(harness.processes.alive.load(Ordering::SeqCst));
9141        assert_eq!(
9142            harness.store.attempt(id).unwrap().unwrap().state(),
9143            AttemptState::Starting
9144        );
9145        assert!(!harness.events.events().iter().any(|event| matches!(
9146            event,
9147            AttemptEvent::Terminated { attempt } | AttemptEvent::Concluded { attempt, .. }
9148                if *attempt == id
9149        )));
9150    }
9151
9152    #[tokio::test]
9153    async fn diagnostics_survive_cleanup_without_the_jit_or_a_token() {
9154        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
9155        harness.ready().await;
9156        let attempt = harness.launch().await;
9157        harness
9158            .github
9159            .observe(GithubRunnerObservation::Registered { busy: false });
9160        harness.launcher.supervise(&harness.policy).await.unwrap();
9161        harness.clock.advance_secs(11);
9162        harness.processes.set_alive(false);
9163        harness
9164            .github
9165            .observe(GithubRunnerObservation::NotRegistered);
9166        harness.launcher.supervise(&harness.policy).await.unwrap();
9167        let diagnostic = fs::read_to_string(
9168            harness
9169                .launcher
9170                .diagnostics_root
9171                .join(format!("{}.log", attempt.id)),
9172        )
9173        .unwrap();
9174        assert!(diagnostic.contains("exited_idle_without_work"));
9175        assert!(!diagnostic.contains(JIT));
9176        assert!(!diagnostic.contains("ghp_"));
9177        assert!(!attempt.runtime_path().exists());
9178    }
9179
9180    #[test]
9181    fn native_process_listing_never_contains_jit_and_handoffs_never_survive() {
9182        let root = tempfile::tempdir().unwrap();
9183        let policy = fixtures::policy()
9184            .repository("octo/repo")
9185            .autoscale("home", 1)
9186            .active()
9187            .build();
9188        let runtime = root.path().join("successful");
9189        fs::create_dir_all(&runtime).unwrap();
9190        let processes = NativeProcesses::new();
9191        let config = EncodedJitConfig::new(JIT);
9192        let handoff =
9193            RestrictiveHandoff::create(&runtime, SecretString::from(config.expose().to_owned()))
9194                .unwrap();
9195        let mut child = native_inspection_spec()
9196            .spawn_runner_with_handoff(&handoff)
9197            .expect("native child starts");
9198        let pid = child.pid();
9199        handoff.delete().unwrap();
9200        let command_line = native_command_line(pid);
9201        assert!(
9202            !command_line.contains(JIT),
9203            "the encoded JIT configuration appeared in the native process listing"
9204        );
9205        assert_no_jit_file(&runtime);
9206        child
9207            .stop(Duration::from_secs(1))
9208            .expect("native child stops");
9209
9210        let failed_runtime = root.path().join("failed");
9211        fs::create_dir_all(&failed_runtime).unwrap();
9212        let failed = RunnerAttempt::allocate(
9213            AttemptId::new_random(),
9214            policy.id,
9215            &failed_runtime,
9216            FakeClock::default().now(),
9217        );
9218        assert!(
9219            processes
9220                .spawn(&failed, &EncodedJitConfig::new(JIT))
9221                .is_err(),
9222            "a runtime with no runner executable must fail"
9223        );
9224        assert_no_jit_file(&failed_runtime);
9225        processes
9226            .record_terminate_intent(&failed)
9227            .expect("the intent file and its directory entry are durably synced");
9228        assert_eq!(
9229            fs::read(NativeProcesses::intent_path(&failed)).unwrap(),
9230            b"registration-timeout\n"
9231        );
9232    }
9233
9234    #[test]
9235    fn post_spawn_boundaries_are_bounded_durable_and_never_retry_jit() {
9236        let root = tempfile::tempdir().unwrap();
9237        let policy = fixtures::policy()
9238            .repository("octo/repo")
9239            .autoscale("home", 1)
9240            .active()
9241            .build();
9242        let processes = NativeProcesses::new();
9243        processes.use_long_lived_test_listener();
9244        for (index, boundary) in [
9245            PostSpawnBoundary::HandoffDelete,
9246            PostSpawnBoundary::IdentitySerialize,
9247            PostSpawnBoundary::IdentityWrite,
9248            PostSpawnBoundary::ChildMapInsert,
9249        ]
9250        .into_iter()
9251        .enumerate()
9252        {
9253            let runtime = root.path().join(format!("post-spawn-{index}"));
9254            let bin = runtime.join("bin");
9255            fs::create_dir_all(&bin).unwrap();
9256            #[cfg(windows)]
9257            let listener = bin.join("Runner.Listener.exe");
9258            #[cfg(not(windows))]
9259            let listener = bin.join("Runner.Listener");
9260            fs::copy(std::env::current_exe().unwrap(), &listener).unwrap();
9261            let attempt = RunnerAttempt::allocate(
9262                AttemptId::new_random(),
9263                policy.id,
9264                &runtime,
9265                FakeClock::default().now(),
9266            );
9267            processes.fail_post_spawn_at(boundary);
9268            let failure = processes
9269                .spawn(&attempt, &EncodedJitConfig::new(JIT))
9270                .expect_err("fault must cross the post-spawn cleanup path");
9271            assert!(!failure.retryable, "{boundary:?} allowed duplicate retry");
9272            assert!(
9273                !processes.is_alive(&attempt).unwrap(),
9274                "{boundary:?} left a child"
9275            );
9276            assert!(!NativeProcesses::identity_path(&attempt).exists());
9277            assert_no_jit_file(&runtime);
9278        }
9279        assert_eq!(processes.post_spawn_reaps.load(Ordering::SeqCst), 4);
9280
9281        let runtime = root.path().join("identity-and-stop-fail");
9282        let bin = runtime.join("bin");
9283        fs::create_dir_all(&bin).unwrap();
9284        #[cfg(windows)]
9285        let listener = bin.join("Runner.Listener.exe");
9286        #[cfg(not(windows))]
9287        let listener = bin.join("Runner.Listener");
9288        fs::copy(std::env::current_exe().unwrap(), &listener).unwrap();
9289        let attempt = RunnerAttempt::allocate(
9290            AttemptId::new_random(),
9291            policy.id,
9292            &runtime,
9293            FakeClock::default().now(),
9294        );
9295        // The first fault rejects the normal identity write; the second rejects
9296        // its retry after the first stop fails. The fallback sidecar must make
9297        // the live-child result durable without an unbounded reap loop.
9298        processes.fail_post_spawn_at(PostSpawnBoundary::IdentityWrite);
9299        processes.fail_post_spawn_at(PostSpawnBoundary::IdentityWrite);
9300        processes.fail_next_post_spawn_stop();
9301        let failure = processes
9302            .spawn(&attempt, &EncodedJitConfig::new(JIT))
9303            .expect_err("the identity boundary must fail closed");
9304        assert!(failure.live_pid.is_some());
9305        assert_long_lived_listener_ready(&processes, &attempt);
9306        assert!(processes.is_alive(&attempt).unwrap());
9307        assert!(!NativeProcesses::identity_path(&attempt).exists());
9308        assert!(NativeProcesses::fallback_identity_path(&attempt).is_file());
9309        assert_eq!(processes.post_spawn_reaps.load(Ordering::SeqCst), 4);
9310        processes.terminate(&attempt).unwrap();
9311
9312        let runtime = root.path().join("persistent-stop-and-identity-failures");
9313        let bin = runtime.join("bin");
9314        fs::create_dir_all(&bin).unwrap();
9315        #[cfg(windows)]
9316        let listener = bin.join("Runner.Listener.exe");
9317        #[cfg(not(windows))]
9318        let listener = bin.join("Runner.Listener");
9319        fs::copy(std::env::current_exe().unwrap(), &listener).unwrap();
9320        let mut unresolved = RunnerAttempt::allocate(
9321            AttemptId::new_random(),
9322            policy.id,
9323            &runtime,
9324            FakeClock::default().now(),
9325        );
9326        for _ in 0..3 {
9327            processes.fail_post_spawn_at(PostSpawnBoundary::IdentityWrite);
9328        }
9329        processes.fail_post_spawn_stops(MAX_POST_SPAWN_STOP_ATTEMPTS);
9330        let failure = processes
9331            .spawn(&unresolved, &EncodedJitConfig::new(JIT))
9332            .expect_err("bounded cleanup must return even when every stop errors");
9333        let pid = failure
9334            .live_pid
9335            .expect("the owned child remains supervised in this invocation");
9336        assert!(matches!(failure.reason, FailureReason::Other(_)));
9337        assert_long_lived_listener_ready(&processes, &unresolved);
9338        unresolved.jit_received(FakeClock::default().now()).unwrap();
9339        unresolved.started(pid, FakeClock::default().now()).unwrap();
9340        let journal = SqliteStore::open_in_memory().unwrap();
9341        journal.record_attempt(&unresolved).unwrap();
9342        let recovered = journal.attempt(unresolved.id).unwrap().unwrap();
9343        assert_eq!(recovered.process_id(), Some(pid));
9344        assert_eq!(recovered.state(), AttemptState::Starting);
9345        assert!(processes.is_alive(&unresolved).unwrap());
9346        assert!(!NativeProcesses::identity_path(&unresolved).exists());
9347        assert!(!NativeProcesses::fallback_identity_path(&unresolved).exists());
9348        assert_eq!(
9349            fs::read_to_string(NativeProcesses::unresolved_process_path(&unresolved)).unwrap(),
9350            pid.to_string(),
9351            "bounded cleanup must leave durable unresolved-process evidence before returning"
9352        );
9353        assert!(
9354            NativeProcesses::new().is_alive(&recovered).is_err(),
9355            "restart must fail closed on the durable starting/PID journal rather than trust a bare PID"
9356        );
9357        processes.terminate(&unresolved).unwrap();
9358
9359        let runtime = root.path().join("post-spawn-stop-failed");
9360        let bin = runtime.join("bin");
9361        fs::create_dir_all(&bin).unwrap();
9362        #[cfg(windows)]
9363        let listener = bin.join("Runner.Listener.exe");
9364        #[cfg(not(windows))]
9365        let listener = bin.join("Runner.Listener");
9366        fs::copy(std::env::current_exe().unwrap(), &listener).unwrap();
9367        let attempt = RunnerAttempt::allocate(
9368            AttemptId::new_random(),
9369            policy.id,
9370            &runtime,
9371            FakeClock::default().now(),
9372        );
9373        processes.fail_post_spawn_at(PostSpawnBoundary::ChildMapInsert);
9374        processes.fail_next_post_spawn_stop();
9375        let failure = processes
9376            .spawn(&attempt, &EncodedJitConfig::new(JIT))
9377            .expect_err("the injected stop failure must preserve supervision");
9378        let live_pid = failure
9379            .live_pid
9380            .expect("live PID is returned to the journal");
9381        assert!(!failure.retryable);
9382        assert_long_lived_listener_ready(&processes, &attempt);
9383        assert!(NativeProcesses::identity_path(&attempt).is_file());
9384        assert_eq!(
9385            NativeProcesses::read_identity(&attempt)
9386                .unwrap()
9387                .unwrap()
9388                .pid(),
9389            live_pid
9390        );
9391        assert_eq!(processes.post_spawn_reaps.load(Ordering::SeqCst), 4);
9392        processes.terminate(&attempt).unwrap();
9393    }
9394
9395    #[test]
9396    #[ignore = "spawned only as the platform-stable native listener fixture"]
9397    fn long_lived_native_listener_helper() {
9398        let ready = std::env::var_os("RUNNER_MANAGER_TEST_LISTENER_READY")
9399            .map(PathBuf::from)
9400            .expect("the parent supplies the readiness path");
9401        // Atomically: the parent polls for the file and must never read it
9402        // created but not yet written.
9403        runner_manager_platform::host_fitness::write_atomically(&ready, b"ready\n")
9404            .expect("the listener publishes readiness");
9405        std::thread::sleep(Duration::from_secs(30));
9406    }
9407
9408    fn assert_long_lived_listener_ready(processes: &NativeProcesses, attempt: &RunnerAttempt) {
9409        let ready = attempt.runtime_path().join(TEST_LISTENER_READY);
9410        let deadline = std::time::Instant::now() + Duration::from_secs(5);
9411        loop {
9412            if ready.is_file() {
9413                assert_eq!(fs::read(&ready).unwrap(), b"ready\n");
9414                return;
9415            }
9416            assert!(
9417                processes.is_alive(attempt).unwrap(),
9418                "the native listener exited before publishing readiness"
9419            );
9420            assert!(
9421                std::time::Instant::now() < deadline,
9422                "the native listener stayed alive but never published readiness"
9423            );
9424            std::thread::sleep(Duration::from_millis(10));
9425        }
9426    }
9427
9428    #[tokio::test]
9429    async fn a_reservation_is_journalled_before_any_effect_and_holds_its_slot() {
9430        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
9431        harness.ready().await;
9432
9433        let reserved = harness.reserve().await.unwrap();
9434
9435        assert_eq!(reserved.state(), AttemptState::Allocated);
9436        assert!(reserved.counts_against_capacity());
9437        let journalled = harness.store.attempt(reserved.id).unwrap().unwrap();
9438        assert_eq!(journalled.state(), AttemptState::Allocated);
9439        assert_eq!(harness.packages.materializations.load(Ordering::SeqCst), 0);
9440        assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
9441
9442        let started = harness
9443            .launcher
9444            .finish_launch(&harness.policy, reserved, ReservationWaits::default())
9445            .await
9446            .unwrap();
9447        assert_eq!(started.state(), AttemptState::Starting);
9448    }
9449
9450    /// A launch runs beside its target's pass: the pass does not read a runner
9451    /// still coming up as one that is gone. It has no process and no registration
9452    /// yet, and the bring-up is writing its journal entry.
9453    #[tokio::test]
9454    async fn a_pass_leaves_a_runner_coming_up_alone_and_supervises_it_once_up() {
9455        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
9456        harness.ready().await;
9457        let reserved = harness.reserve().await.unwrap();
9458        let looks_left = || harness.github.observations.lock().unwrap().len();
9459        harness
9460            .github
9461            .observations
9462            .lock()
9463            .unwrap()
9464            .push_back(LifecycleGithubObservation::not_registered());
9465
9466        harness.launcher.supervise(&harness.policy).await.unwrap();
9467        assert_eq!(looks_left(), 1, "it did not look");
9468        let journalled = harness.store.attempt(reserved.id).unwrap().unwrap();
9469        assert_eq!(journalled.state(), AttemptState::Allocated);
9470
9471        harness
9472            .launcher
9473            .finish_launch(&harness.policy, reserved, ReservationWaits::default())
9474            .await
9475            .unwrap();
9476        assert!(harness.launcher.bringing_up.lock().unwrap().is_empty());
9477
9478        harness.launcher.supervise(&harness.policy).await.unwrap();
9479        assert_eq!(looks_left(), 0, "up, it is this pass's to supervise");
9480    }
9481
9482    /// A daemon that dies between the reservation and the process: the
9483    /// journalled attempt is what recovery finds, and the slot comes back.
9484    #[tokio::test]
9485    async fn a_crash_between_reservation_and_spawn_recovers_the_slot() {
9486        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
9487        harness.ready().await;
9488        let reserved = harness.reserve().await.unwrap();
9489        harness.clock.advance_secs(31);
9490
9491        harness
9492            .launcher
9493            .recover_startup(std::slice::from_ref(&harness.policy))
9494            .await
9495            .unwrap();
9496
9497        let recovered = harness.store.attempt(reserved.id).unwrap().unwrap();
9498        assert!(
9499            !recovered.counts_against_capacity(),
9500            "the slot is free again: {:?}",
9501            recovered.state()
9502        );
9503    }
9504
9505    /// A crash once the package is there, before the process: same result.
9506    #[tokio::test]
9507    async fn a_crash_after_the_package_is_copied_recovers_the_slot() {
9508        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
9509        harness.ready().await;
9510        let reserved = harness.reserve().await.unwrap();
9511        harness
9512            .packages
9513            .materialize(&reserved, &mut PackageTimings::default())
9514            .await
9515            .unwrap();
9516        harness.clock.advance_secs(31);
9517
9518        harness
9519            .launcher
9520            .recover_startup(std::slice::from_ref(&harness.policy))
9521            .await
9522            .unwrap();
9523
9524        let recovered = harness.store.attempt(reserved.id).unwrap().unwrap();
9525        assert!(!recovered.counts_against_capacity());
9526    }
9527
9528    #[tokio::test]
9529    async fn runners_prepare_at_once_within_the_bound() {
9530        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
9531        harness.ready().await;
9532        let turns = harness.launcher.turns.available_permits();
9533        harness.launcher.turns.forget_permits(turns - 2);
9534        let mut reserved = Vec::new();
9535        for _ in 0..4 {
9536            reserved.push(harness.reserve().await.unwrap());
9537        }
9538
9539        let finished = futures::future::join_all(reserved.into_iter().map(|attempt| {
9540            harness
9541                .launcher
9542                .finish_launch(&harness.policy, attempt, ReservationWaits::default())
9543        }))
9544        .await;
9545
9546        assert!(finished.iter().all(Result::is_ok));
9547        assert_eq!(
9548            harness.packages.peak_materializing.load(Ordering::SeqCst),
9549            2
9550        );
9551    }
9552
9553    #[tokio::test]
9554    async fn every_production_launch_prunes_under_the_same_allocation_guard() {
9555        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
9556        harness.ready().await;
9557        assert_eq!(harness.packages.prunes.load(Ordering::SeqCst), 0);
9558        harness.launch().await;
9559        assert_eq!(harness.packages.prunes.load(Ordering::SeqCst), 1);
9560    }
9561
9562    fn copy_package_tree(
9563        source: &Path,
9564        destination: &Path,
9565        seeds: &TrustedTrees,
9566    ) -> std::io::Result<()> {
9567        let origin = copy_origin(source, destination, seeds);
9568        copy_package_from(source, &origin, destination, seeds)
9569    }
9570
9571    fn assert_no_jit_file(runtime: &Path) {
9572        for entry in fs::read_dir(runtime).unwrap() {
9573            let path = entry.unwrap().path();
9574            if path.is_file() {
9575                let bytes = fs::read(&path).unwrap();
9576                assert!(
9577                    !bytes
9578                        .windows(JIT.len())
9579                        .any(|window| window == JIT.as_bytes()),
9580                    "a JIT payload survived in a runtime file"
9581                );
9582            }
9583        }
9584    }
9585
9586    #[test]
9587    fn production_listener_command_uses_the_supported_jit_contract() {
9588        let runtime = Path::new("runtime");
9589        let spec = runner_listener_spec(
9590            PathBuf::from("Runner.Listener"),
9591            runtime,
9592            Vec::new(),
9593            &RunnerEnv::default(),
9594        );
9595        let arguments: Vec<_> = spec
9596            .arguments()
9597            .iter()
9598            .map(|argument| argument.to_string_lossy().into_owned())
9599            .collect();
9600
9601        assert_eq!(arguments, ["run"]);
9602        assert!(
9603            !arguments
9604                .iter()
9605                .any(|argument| argument == "--jit-config-file"),
9606            "the obsolete file option would be rejected by Runner.Listener 2.336.0"
9607        );
9608    }
9609
9610    /// What a production runner starts with: the platform defaults, the host's
9611    /// `runner.env` over them, the attempt's own temporary directory, and
9612    /// normal scheduling.
9613    #[test]
9614    fn production_listener_layers_runner_env_over_the_platform_defaults() {
9615        let root = tempfile::tempdir().unwrap();
9616        let runtime = root.path().join("attempt");
9617        let host_env =
9618            RunnerEnv::parse("ELECTRON_CACHE=/cache/electron\nHOME=/operator/chose/this\n")
9619                .unwrap();
9620        let caches = vec![
9621            (
9622                "ELECTRON_CACHE",
9623                std::ffi::OsString::from("/rman/_cache/o/r/electron"),
9624            ),
9625            (
9626                "npm_config_cache",
9627                std::ffi::OsString::from("/rman/_cache/o/r/npm"),
9628            ),
9629        ];
9630        let spec = runner_listener_spec(
9631            PathBuf::from("Runner.Listener"),
9632            &runtime,
9633            caches,
9634            &host_env,
9635        );
9636        let env = |name: &str| spec.configured_env(name).map(PathBuf::from);
9637
9638        assert!(spec.expects_normal_scheduling());
9639        assert_eq!(
9640            env("ELECTRON_CACHE"),
9641            Some(PathBuf::from("/cache/electron")),
9642            "runner.env must win over a dependency cache of the same name"
9643        );
9644        assert_eq!(
9645            env("npm_config_cache"),
9646            Some(PathBuf::from("/rman/_cache/o/r/npm"))
9647        );
9648        assert_eq!(
9649            env("HOME"),
9650            Some(PathBuf::from("/operator/chose/this")),
9651            "runner.env must win over a platform default of the same name"
9652        );
9653        for tmp in ["TMPDIR", "TEMP", "TMP"] {
9654            assert_eq!(env(tmp), Some(runtime.join("tmp")), "{tmp}");
9655        }
9656
9657        let home = runtime.join(runner_env::RUNNER_HOME_DIR);
9658        if cfg!(windows) {
9659            // Each runner's own profile, created inside its attempt, so that
9660            // concurrent jobs stop sharing the service account's.
9661            assert_eq!(env("USERPROFILE"), Some(home.clone()));
9662            assert_eq!(env("APPDATA"), Some(home.join(r"AppData\Roaming")));
9663            assert_eq!(env("LOCALAPPDATA"), Some(home.join(r"AppData\Local")));
9664            assert!(home.join(r"AppData\Roaming").is_dir());
9665            assert!(home.join(r"AppData\Local").is_dir());
9666        } else {
9667            // Unix keeps the account's own profile.
9668            assert_eq!(env("USERPROFILE"), None);
9669            assert!(!home.exists());
9670        }
9671    }
9672
9673    /// Every step shell a runner starts inherits PowerShell's telemetry
9674    /// opt-out from the listener, unless the daemon or `runner.env` decided
9675    /// otherwise; see [`runner_env::POWERSHELL_TELEMETRY_OPTOUT`].
9676    #[test]
9677    fn production_listener_opts_powershell_out_of_telemetry() {
9678        let root = tempfile::tempdir().unwrap();
9679        let runtime = root.path().join("attempt");
9680        let listener = |host_env: &RunnerEnv| {
9681            runner_listener_spec(
9682                PathBuf::from("Runner.Listener"),
9683                &runtime,
9684                Vec::new(),
9685                host_env,
9686            )
9687        };
9688        let optout = |spec: &SpawnSpec| {
9689            spec.configured_env(runner_env::POWERSHELL_TELEMETRY_OPTOUT)
9690                .map(OsStr::to_os_string)
9691        };
9692
9693        let spec = listener(&RunnerEnv::default());
9694        if std::env::var_os(runner_env::POWERSHELL_TELEMETRY_OPTOUT)
9695            .is_some_and(|value| !value.is_empty())
9696        {
9697            // The daemon's own value is inherited as it is.
9698            assert_eq!(optout(&spec), None);
9699        } else {
9700            assert_eq!(
9701                optout(&spec),
9702                Some(runner_env::POWERSHELL_TELEMETRY_OPTOUT_VALUE.into())
9703            );
9704        }
9705
9706        let operator = RunnerEnv::parse("POWERSHELL_TELEMETRY_OPTOUT=0\n").unwrap();
9707        assert_eq!(
9708            optout(&listener(&operator)),
9709            Some("0".into()),
9710            "runner.env must win over the telemetry opt-out"
9711        );
9712    }
9713
9714    #[test]
9715    fn an_invalid_runner_env_fails_the_launch_without_echoing_a_value() {
9716        let root = tempfile::tempdir().unwrap();
9717        let path = runner_env::path_in(root.path());
9718        fs::write(&path, "GOOD=1\nghp_notarealtokenbutshapedlikeone\n").unwrap();
9719        let processes = NativeProcesses::new().with_runner_env_file(path.clone());
9720        let failure = processes.host_env().unwrap_err().to_string();
9721        assert!(
9722            failure.contains("runner.env") && failure.contains("line 2"),
9723            "{failure}"
9724        );
9725        assert!(
9726            !failure.contains("ghp_"),
9727            "the failure echoed a value: {failure}"
9728        );
9729
9730        // `prepare` runs before the JIT registration, so a broken file is
9731        // refused there, before GitHub is asked for anything.
9732        let policy = fixtures::policy()
9733            .repository("octo/repo")
9734            .autoscale("home", 1)
9735            .active()
9736            .build();
9737        let attempt = RunnerAttempt::allocate(
9738            AttemptId::new_random(),
9739            policy.id,
9740            root.path(),
9741            FakeClock::default().now(),
9742        );
9743        assert!(processes.prepare(&attempt, &policy).is_err());
9744
9745        fs::write(&path, "GOOD=1\n").unwrap();
9746        assert!(processes.prepare(&attempt, &policy).is_ok());
9747        assert_eq!(processes.host_env().unwrap().get("GOOD"), Some("1"));
9748        assert!(NativeProcesses::new().host_env().unwrap().is_empty());
9749    }
9750
9751    /// The wiring from `prepare` (which sees the policy) to `spawn` (which
9752    /// does not): the policy's repository decides the namespace, and a broken
9753    /// `caches.toml` is refused before GitHub is asked for anything.
9754    #[test]
9755    fn prepare_hands_the_policys_cache_namespace_to_the_launch() {
9756        let root = tempfile::tempdir().unwrap();
9757        let runner_root = root.path().join("rman");
9758        let runtime = runner_root.join("a1");
9759        fs::create_dir_all(runtime.join("bin")).unwrap();
9760        let config = runner_manager_platform::dependency_cache::config_path_in(root.path());
9761        let source_root = runner_root.clone();
9762        let processes = NativeProcesses::new().with_dependency_caches(DependencyCaches::new(
9763            config.clone(),
9764            Arc::new(move || Some(source_root.clone())),
9765            None,
9766        ));
9767        let policy = fixtures::policy()
9768            .repository("Octo/Repo")
9769            .autoscale("home", 1)
9770            .active()
9771            .build();
9772        let attempt = RunnerAttempt::allocate(
9773            AttemptId::new_random(),
9774            policy.id,
9775            &runtime,
9776            FakeClock::default().now(),
9777        );
9778        assert!(
9779            processes
9780                .cache_env(&attempt, None, &RunnerEnv::default())
9781                .is_empty(),
9782            "a launch with no prepared target, such as a bare `spawn`, has no cache"
9783        );
9784        let prepared = processes.prepare(&attempt, &policy).unwrap();
9785        let variables = processes.cache_env(
9786            &attempt,
9787            prepared.cache_target.as_ref(),
9788            &RunnerEnv::default(),
9789        );
9790        let npm = variables
9791            .iter()
9792            .find(|(name, _)| *name == "npm_config_cache")
9793            .map(|(_, value)| PathBuf::from(value));
9794        assert_eq!(
9795            npm,
9796            Some(
9797                runner_root
9798                    .join("_cache")
9799                    .join("octo")
9800                    .join("repo")
9801                    .join("npm")
9802            )
9803        );
9804
9805        fs::write(&config, "[tools]\nnot-a-tool = true\n").unwrap();
9806        let refused = processes
9807            .prepare(&attempt, &policy)
9808            .unwrap_err()
9809            .to_string();
9810        assert!(refused.contains("caches.toml"), "{refused}");
9811    }
9812
9813    #[cfg(windows)]
9814    fn native_inspection_spec() -> SpawnSpec {
9815        SpawnSpec::new("powershell.exe").args([
9816            "-NoProfile",
9817            "-NonInteractive",
9818            "-Command",
9819            "Start-Sleep -Seconds 30",
9820        ])
9821    }
9822
9823    #[cfg(unix)]
9824    fn native_inspection_spec() -> SpawnSpec {
9825        SpawnSpec::new("/bin/sh").args(["-c", "sleep 30"])
9826    }
9827
9828    #[cfg(windows)]
9829    fn native_command_line(pid: u32) -> String {
9830        let output = std::process::Command::new("powershell.exe")
9831            .args([
9832                "-NoProfile",
9833                "-NonInteractive",
9834                "-Command",
9835                &format!("(Get-CimInstance Win32_Process -Filter 'ProcessId = {pid}').CommandLine"),
9836            ])
9837            .output()
9838            .expect("PowerShell can inspect the native child");
9839        assert!(output.status.success(), "native process inspection failed");
9840        String::from_utf8(output.stdout).expect("Windows command lines are Unicode")
9841    }
9842
9843    #[cfg(target_os = "linux")]
9844    fn native_command_line(pid: u32) -> String {
9845        fs::read(format!("/proc/{pid}/cmdline"))
9846            .map(|bytes| String::from_utf8_lossy(&bytes).replace('\0', " "))
9847            .expect("/proc exposes the native child command line")
9848    }
9849
9850    #[cfg(target_os = "macos")]
9851    fn native_command_line(pid: u32) -> String {
9852        let output = std::process::Command::new("ps")
9853            .args(["-o", "command=", "-p", &pid.to_string()])
9854            .output()
9855            .expect("ps can inspect the native child");
9856        assert!(output.status.success(), "native process inspection failed");
9857        String::from_utf8(output.stdout).expect("the command line is UTF-8")
9858    }
9859
9860    // -- c2: persistent slot allocation -------------------------------------
9861
9862    #[tokio::test]
9863    async fn a_persistent_repository_leases_s1_and_journals_it_before_any_github_effect() {
9864        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
9865            .with_persistent_workspace(2);
9866        harness.github.watch_journal(Arc::clone(&harness.store));
9867        harness.ready().await;
9868
9869        let attempt = harness.launch().await;
9870
9871        assert_eq!(
9872            attempt.workspace(),
9873            AttemptWorkspace::persistent_slot(nz(1)),
9874            "the lowest free slot is leased"
9875        );
9876        assert_eq!(attempt.runtime_path(), harness.slot_path(1));
9877        assert!(attempt.holds_slot_lease());
9878        // The exact runtime path is journalled, not re-derived later.
9879        assert_eq!(
9880            harness.attempt(attempt.id).runtime_path(),
9881            harness.slot_path(1)
9882        );
9883
9884        // Step 7 of "Slot allocation": the lease exists before GitHub is asked
9885        // for anything, and the runner's work folder stays the relative `_work`
9886        // the slot root is laid out around.
9887        let facts = harness.github.registration_facts();
9888        assert_eq!(facts.len(), 1);
9889        assert_eq!(
9890            facts[0].leased_slots,
9891            vec![1],
9892            "the lease was journalled first"
9893        );
9894        assert_eq!(facts[0].work_folder, DEFAULT_WORK_FOLDER);
9895    }
9896
9897    #[tokio::test]
9898    async fn a_terminal_but_uncleaned_attempt_keeps_its_slot_without_holding_capacity() {
9899        let harness = Harness::new(
9900            FakeGithubLifecycle::default().fail(true),
9901            Arc::new(PersistentDemand),
9902        )
9903        .with_persistent_workspace(2);
9904        harness.ready().await;
9905
9906        // A terminal JIT refusal concludes the attempt without cleaning it.
9907        harness.launch_result().await.unwrap_err();
9908        let first = harness.store.attempts().unwrap().remove(0);
9909        assert_eq!(first.state(), AttemptState::Failed);
9910        assert!(
9911            !first.state().counts_against_capacity(),
9912            "a concluded attempt is invisible to host capacity"
9913        );
9914        assert!(
9915            first.holds_slot_lease(),
9916            "and still owns its directory, so its slot is not free"
9917        );
9918
9919        let second = harness.launch().await;
9920        assert_eq!(second.workspace(), AttemptWorkspace::persistent_slot(nz(2)));
9921        assert_eq!(second.runtime_path(), harness.slot_path(2));
9922        assert_eq!(
9923            harness
9924                .store
9925                .slot_leases_for_policy(harness.policy.id)
9926                .unwrap()
9927                .len(),
9928            2
9929        );
9930    }
9931
9932    #[tokio::test]
9933    async fn two_sequential_allocations_at_capacity_one_reuse_s1_and_its_retained_work() {
9934        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
9935            .with_persistent_workspace(1);
9936        harness.ready().await;
9937
9938        let first = harness.launch().await;
9939        assert_eq!(first.runtime_path(), harness.slot_path(1));
9940
9941        // What a job leaves behind, at the path the runner writes it to.
9942        let checkout = harness.slot_path(1).join(DEFAULT_WORK_FOLDER).join("repo");
9943        fs::create_dir_all(&checkout).unwrap();
9944        fs::write(checkout.join("checkout.txt"), b"from the first job").unwrap();
9945
9946        harness.cleanup_retaining_work(first.id).await;
9947
9948        let second = harness.launch().await;
9949        assert_ne!(second.id, first.id);
9950        assert_eq!(
9951            second.workspace(),
9952            AttemptWorkspace::persistent_slot(nz(1)),
9953            "a released slot is leased again rather than skipped"
9954        );
9955        assert_eq!(
9956            second.runtime_path(),
9957            first.runtime_path(),
9958            "the same slot is the same exact path"
9959        );
9960        assert_eq!(
9961            fs::read_to_string(checkout.join("checkout.txt")).unwrap(),
9962            "from the first job",
9963            "the retained job workspace survived the second allocation"
9964        );
9965        // The attempt's own runner material was recreated for this attempt.
9966        assert!(harness.slot_path(1).join("runner-package").exists());
9967    }
9968
9969    #[tokio::test]
9970    async fn lowering_capacity_leaves_higher_slots_alone_and_raising_it_permits_them_again() {
9971        let mut harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
9972            .with_persistent_workspace(2);
9973        harness.ready().await;
9974
9975        let first = harness.launch().await;
9976        let second = harness.launch().await;
9977        assert_eq!(second.runtime_path(), harness.slot_path(2));
9978        let kept = harness
9979            .slot_path(2)
9980            .join(DEFAULT_WORK_FOLDER)
9981            .join("kept.txt");
9982        fs::create_dir_all(kept.parent().unwrap()).unwrap();
9983        fs::write(&kept, b"s2 was here").unwrap();
9984        harness.cleanup_retaining_work(second.id).await;
9985
9986        // The operator lowers the ceiling while s1 is still leased.
9987        harness.policy.set_max_capacity(nz(1)).unwrap();
9988        let refusal = harness.launch_result().await.unwrap_err().to_string();
9989        assert!(
9990            refusal.contains("s1 to s1"),
9991            "the refusal names the ceiling it reached: {refusal}"
9992        );
9993        assert!(
9994            harness.slot_path(2).exists() && kept.exists(),
9995            "lowering capacity deletes nothing; the higher slot is merely unusable"
9996        );
9997
9998        // Raising it again makes the free higher slot available.
9999        harness.policy.set_max_capacity(nz(2)).unwrap();
10000        let third = harness.launch().await;
10001        assert_eq!(third.workspace(), AttemptWorkspace::persistent_slot(nz(2)));
10002        assert_eq!(third.runtime_path(), harness.slot_path(2));
10003        assert_eq!(fs::read_to_string(&kept).unwrap(), "s2 was here");
10004        assert!(first.holds_slot_lease(), "s1 was never disturbed");
10005    }
10006
10007    #[tokio::test]
10008    async fn organization_and_ephemeral_policies_never_enter_slot_allocation() {
10009        for policy in [
10010            fixtures::policy()
10011                .organization("octo")
10012                .autoscale("home", 2)
10013                .active()
10014                .build(),
10015            fixtures::policy()
10016                .repository("octo/repo")
10017                .autoscale("home", 2)
10018                .active()
10019                .build(),
10020        ] {
10021            let mut harness =
10022                Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
10023                    .with_host_runner_root();
10024            assert_eq!(policy.workspace_policy(), &WorkspacePolicy::Ephemeral);
10025            harness.policy = policy;
10026            harness.ready().await;
10027
10028            let attempt = harness.launch().await;
10029            assert_eq!(attempt.workspace(), AttemptWorkspace::Ephemeral);
10030            assert_eq!(attempt.workspace().slot_number(), None);
10031            assert!(!attempt.holds_slot_lease());
10032            assert_eq!(
10033                attempt.runtime_path().parent().unwrap(),
10034                harness.host_root(),
10035                "a disposable attempt is a child of the host root, never of a slot"
10036            );
10037            assert!(
10038                harness
10039                    .store
10040                    .slot_leases_for_policy(harness.policy.id)
10041                    .unwrap()
10042                    .is_empty()
10043            );
10044        }
10045    }
10046
10047    #[tokio::test]
10048    async fn two_concurrent_allocations_never_share_a_slot() {
10049        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
10050            .with_persistent_workspace(2);
10051        harness.github.watch_journal(Arc::clone(&harness.store));
10052        harness.ready().await;
10053
10054        // Both allocators race for the same host allocation lock, which is what
10055        // orders slot *selection*; each one then journals its lease before it
10056        // asks GitHub for anything.
10057        let (first, second) = tokio::join!(harness.launch_result(), harness.launch_result());
10058        let first = first.unwrap();
10059        let second = second.unwrap();
10060
10061        let slots: BTreeSet<u16> = [&first, &second]
10062            .iter()
10063            .map(|attempt| {
10064                attempt
10065                    .workspace()
10066                    .slot_number()
10067                    .expect("a persistent attempt leases a slot")
10068            })
10069            .collect();
10070        assert_eq!(slots, BTreeSet::from([1, 2]), "one slot each, never shared");
10071        assert_ne!(first.runtime_path(), second.runtime_path());
10072        assert_eq!(
10073            harness
10074                .store
10075                .slot_leases_for_policy(harness.policy.id)
10076                .unwrap()
10077                .len(),
10078            2
10079        );
10080
10081        // Every registration saw *its own* lease already in the journal. Reading
10082        // the whole journal and asking only that it be non-empty would pass on
10083        // the other allocator's lease, which is precisely the ordering bug this
10084        // test exists to exclude.
10085        let facts = harness.github.registration_facts();
10086        assert_eq!(facts.len(), 2);
10087        for fact in facts {
10088            let attempt = [&first, &second]
10089                .into_iter()
10090                .find(|attempt| runner_name(attempt.id) == fact.runner_name)
10091                .expect("every registration belongs to one of the two attempts");
10092            let slot = attempt
10093                .workspace()
10094                .slot_number()
10095                .expect("a persistent attempt leases a slot");
10096            assert!(
10097                fact.leased_slots.contains(&slot),
10098                "a JIT request never precedes its own lease: s{slot} not in {:?}",
10099                fact.leased_slots
10100            );
10101            assert_eq!(fact.work_folder, DEFAULT_WORK_FOLDER);
10102        }
10103    }
10104
10105    #[tokio::test]
10106    async fn the_database_is_the_final_fence_against_two_attempts_in_one_slot() {
10107        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
10108            .with_persistent_workspace(2);
10109        harness.ready().await;
10110        let first = harness.launch().await;
10111
10112        // What a second allocator that lost the race would write: the lock
10113        // orders selection, and this index is what catches a writer the lock
10114        // could not see.
10115        let clash = RunnerAttempt::allocate_in(
10116            AttemptId::new_random(),
10117            harness.policy.id,
10118            first.runtime_path(),
10119            AttemptWorkspace::persistent_slot(nz(1)),
10120            harness.clock.now(),
10121        );
10122        assert!(matches!(
10123            harness.store.record_attempt(&clash).unwrap_err(),
10124            StoreError::SlotAlreadyLeased { slot: 1, .. }
10125        ));
10126
10127        // And the launcher reports it as itself rather than as a generic
10128        // journal failure, so the operator reads what actually happened.
10129        let error = harness.launcher.record_allocation(&clash).unwrap_err();
10130        let rendered = error.to_string();
10131        assert!(rendered.contains("slot s1"), "{rendered}");
10132        assert!(rendered.contains("nothing was written"), "{rendered}");
10133        assert_eq!(
10134            harness.store.attempts().unwrap().len(),
10135            1,
10136            "the losing allocator journalled nothing"
10137        );
10138    }
10139
10140    #[test]
10141    fn slot_selection_fills_the_lowest_gap_and_stops_at_the_ceiling() {
10142        let leased = |slots: &[u16]| -> Vec<RunnerAttempt> {
10143            slots
10144                .iter()
10145                .map(|slot| {
10146                    RunnerAttempt::allocate_in(
10147                        AttemptId::new_random(),
10148                        fixtures::POLICY_ID,
10149                        format!("/srv/rman/acme/s{slot}"),
10150                        AttemptWorkspace::persistent_slot(nz(*slot)),
10151                        fixtures::created_at(),
10152                    )
10153                })
10154                .collect()
10155        };
10156
10157        assert_eq!(lowest_free_slot(&[], nz(1)), Some(nz(1)));
10158        assert_eq!(lowest_free_slot(&leased(&[1]), nz(4)), Some(nz(2)));
10159        // The gap a released middle slot leaves is filled before the tail.
10160        assert_eq!(lowest_free_slot(&leased(&[1, 3]), nz(4)), Some(nz(2)));
10161        // The ceiling is a refusal, never a reason to allocate past it.
10162        assert_eq!(lowest_free_slot(&leased(&[1]), nz(1)), None);
10163        assert_eq!(lowest_free_slot(&leased(&[1, 2]), nz(2)), None);
10164        // An ephemeral attempt holds no slot and cannot block one.
10165        let ephemeral = vec![RunnerAttempt::allocate(
10166            AttemptId::new_random(),
10167            fixtures::POLICY_ID,
10168            "/srv/rman/host/abc",
10169            fixtures::created_at(),
10170        )];
10171        assert_eq!(lowest_free_slot(&ephemeral, nz(1)), Some(nz(1)));
10172    }
10173
10174    #[test]
10175    fn a_slot_is_reusable_only_when_it_is_empty_or_holds_one_real_work_directory() {
10176        let root = tempfile::tempdir().unwrap();
10177        let slot = root.path().join("s1");
10178        fs::create_dir(&slot).unwrap();
10179        accept_reusable_slot(&slot).expect("an empty slot is reusable");
10180
10181        fs::create_dir(slot.join(DEFAULT_WORK_FOLDER)).unwrap();
10182        accept_reusable_slot(&slot).expect("a retained job workspace is reusable");
10183
10184        // Runner material a previous attempt left behind is refused rather than
10185        // reused or removed: deciding those bytes are safe is cleanup's job.
10186        fs::create_dir(slot.join("bin")).unwrap();
10187        fs::write(slot.join(".github-runner-id"), b"73").unwrap();
10188        let refusal = accept_reusable_slot(&slot).unwrap_err().to_string();
10189        assert!(refusal.contains("bin"), "{refusal}");
10190        assert!(refusal.contains(".github-runner-id"), "{refusal}");
10191
10192        // A `_work` that is not a real directory is not a job workspace.
10193        let file_work = root.path().join("s2");
10194        fs::create_dir(&file_work).unwrap();
10195        fs::write(file_work.join(DEFAULT_WORK_FOLDER), b"not a directory").unwrap();
10196        assert!(accept_reusable_slot(&file_work).is_err());
10197    }
10198
10199    #[cfg(unix)]
10200    #[test]
10201    fn a_link_shaped_work_directory_is_refused_rather_than_followed() {
10202        // Windows needs a privilege to create either kind of link, so the
10203        // link-shaped cases are asserted here; the rule itself is
10204        // platform-independent because it is `symlink_metadata`'s answer.
10205        let root = tempfile::tempdir().unwrap();
10206        let elsewhere = root.path().join("elsewhere");
10207        fs::create_dir(&elsewhere).unwrap();
10208
10209        let slot = root.path().join("s1");
10210        fs::create_dir(&slot).unwrap();
10211        std::os::unix::fs::symlink(&elsewhere, slot.join(DEFAULT_WORK_FOLDER)).unwrap();
10212        assert!(accept_reusable_slot(&slot).is_err());
10213
10214        let linked_slot = root.path().join("s2");
10215        std::os::unix::fs::symlink(&elsewhere, &linked_slot).unwrap();
10216        assert!(create_or_validate_slot(&linked_slot).is_err());
10217    }
10218
10219    #[test]
10220    fn a_slot_standing_where_a_file_is_refuses_rather_than_replacing_it() {
10221        let root = tempfile::tempdir().unwrap();
10222        let occupied = root.path().join("s1");
10223        fs::write(&occupied, b"an operator's file").unwrap();
10224        let refusal = create_or_validate_slot(&occupied).unwrap_err().to_string();
10225        assert!(refusal.contains("is not a directory"), "{refusal}");
10226        assert_eq!(fs::read_to_string(&occupied).unwrap(), "an operator's file");
10227
10228        let fresh = root.path().join("s2");
10229        create_or_validate_slot(&fresh).expect("a missing slot is created");
10230        assert!(fresh.is_dir());
10231        create_or_validate_slot(&fresh).expect("an existing directory is accepted");
10232    }
10233
10234    #[test]
10235    fn the_retained_work_directory_is_matched_the_way_the_filesystem_matches_it() {
10236        assert!(is_work_folder(OsStr::new(DEFAULT_WORK_FOLDER)));
10237        assert!(!is_work_folder(OsStr::new("_work2")));
10238        // A Windows filesystem is case-insensitive, so `_Work` *is* the retained
10239        // job workspace there and must never be removed as a leftover; on a
10240        // case-sensitive filesystem it is a different directory entirely.
10241        assert_eq!(is_work_folder(OsStr::new("_Work")), cfg!(windows));
10242    }
10243
10244    #[test]
10245    fn package_materialization_never_overwrites_or_follows_a_retained_work_directory() {
10246        let root = tempfile::tempdir().unwrap();
10247        let package = root.path().join("package");
10248        fs::create_dir_all(package.join("bin")).unwrap();
10249        fs::write(package.join("bin").join("Runner.Listener"), b"binary").unwrap();
10250        // A nested `_work` inside the package's own tree is an ordinary name.
10251        fs::create_dir_all(package.join("externals").join(DEFAULT_WORK_FOLDER)).unwrap();
10252
10253        let slot = root.path().join("s1");
10254        let retained = slot.join(DEFAULT_WORK_FOLDER).join("repo");
10255        fs::create_dir_all(&retained).unwrap();
10256        fs::write(retained.join("checkout.txt"), b"from the first job").unwrap();
10257
10258        copy_package_tree(&package, &slot, &TrustedTrees::default())
10259            .expect("the package lays out around `_work`");
10260        assert!(slot.join("bin").join("Runner.Listener").exists());
10261        assert!(
10262            slot.join("externals").join(DEFAULT_WORK_FOLDER).is_dir(),
10263            "the guard is top-level only"
10264        );
10265        assert_eq!(
10266            fs::read_to_string(retained.join("checkout.txt")).unwrap(),
10267            "from the first job"
10268        );
10269
10270        // A package that ever grew a top-level `_work` is refused, not merged.
10271        fs::create_dir(package.join(DEFAULT_WORK_FOLDER)).unwrap();
10272        let error = copy_package_tree(&package, &slot, &TrustedTrees::default()).unwrap_err();
10273        assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
10274        assert_eq!(
10275            fs::read_to_string(retained.join("checkout.txt")).unwrap(),
10276            "from the first job"
10277        );
10278    }
10279
10280    #[test]
10281    fn rolling_back_a_materialization_keeps_a_slot_but_removes_a_disposable_directory() {
10282        let root = tempfile::tempdir().unwrap();
10283
10284        let slot = root.path().join("s1");
10285        let retained = slot.join(DEFAULT_WORK_FOLDER);
10286        fs::create_dir_all(retained.join("repo")).unwrap();
10287        fs::write(retained.join("repo").join("checkout.txt"), b"kept").unwrap();
10288        fs::create_dir_all(slot.join("bin")).unwrap();
10289        fs::write(slot.join(".github-runner-id"), b"73").unwrap();
10290        let persistent = RunnerAttempt::allocate_in(
10291            AttemptId::new_random(),
10292            fixtures::POLICY_ID,
10293            &slot,
10294            AttemptWorkspace::persistent_slot(nz(1)),
10295            fixtures::created_at(),
10296        );
10297
10298        remove_materialized_package(&persistent).unwrap();
10299        assert!(slot.is_dir(), "the slot itself is not removed");
10300        assert!(!slot.join("bin").exists());
10301        assert!(!slot.join(".github-runner-id").exists());
10302        assert_eq!(
10303            fs::read_to_string(retained.join("repo").join("checkout.txt")).unwrap(),
10304            "kept"
10305        );
10306
10307        let disposable_path = root.path().join("abcdef012345");
10308        fs::create_dir_all(disposable_path.join(DEFAULT_WORK_FOLDER)).unwrap();
10309        let disposable = RunnerAttempt::allocate(
10310            AttemptId::new_random(),
10311            fixtures::POLICY_ID,
10312            &disposable_path,
10313            fixtures::created_at(),
10314        );
10315        remove_materialized_package(&disposable).unwrap();
10316        assert!(
10317            !disposable_path.exists(),
10318            "a disposable directory is still removed whole"
10319        );
10320    }
10321
10322    // -- c3: persistent cleanup and recovery --------------------------------
10323
10324    /// The runner state one attempt leaves at a slot root, as a real attempt
10325    /// leaves it: binaries, registration identity, a JIT handoff that outlived
10326    /// its process, and this agent's own lifecycle sidecars.
10327    ///
10328    /// Driven by [`SENSITIVE_SLOT_ENTRIES`] rather than by a second copy of it,
10329    /// so a name added to the thing cleanup must prove absent is a name every
10330    /// test here starts leaving behind.
10331    fn litter_the_slot(slot: &Path) {
10332        for directory in ["bin", "externals", "_diag"] {
10333            fs::create_dir_all(slot.join(directory)).unwrap();
10334        }
10335        fs::write(slot.join("bin").join("Runner.Listener"), b"binary").unwrap();
10336        for file in SENSITIVE_SLOT_ENTRIES
10337            .iter()
10338            .filter(|entry| !slot.join(entry).is_dir())
10339        {
10340            fs::write(slot.join(file), b"runner state").unwrap();
10341        }
10342        // A handoff whose owning process died before `Drop` could delete it.
10343        fs::write(
10344            slot.join(format!(
10345                "{}0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0.tmp",
10346                RestrictiveHandoff::NAME_PREFIX
10347            )),
10348            JIT.as_bytes(),
10349        )
10350        .unwrap();
10351    }
10352
10353    /// A marker under `_work` of the kind a job leaves for the next one.
10354    fn retain_under_work(slot: &Path) -> PathBuf {
10355        let checkout = slot.join(DEFAULT_WORK_FOLDER).join("repo").join("target");
10356        fs::create_dir_all(&checkout).unwrap();
10357        let marker = checkout.join("build-output.bin");
10358        fs::write(&marker, RETAINED).unwrap();
10359        marker
10360    }
10361
10362    /// What a job leaves under `_work` for the next job to reuse.
10363    const RETAINED: &str = "a Git-ignored build output the next job reuses";
10364
10365    /// Every direct entry of a directory, sorted, as plain strings.
10366    fn entries_of(directory: &Path) -> Vec<String> {
10367        let mut names: Vec<String> = fs::read_dir(directory)
10368            .unwrap()
10369            .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
10370            .collect();
10371        names.sort();
10372        names
10373    }
10374
10375    /// The one entry a cleaned slot is allowed to hold.
10376    fn only_the_job_workspace() -> Vec<String> {
10377        vec![DEFAULT_WORK_FOLDER.to_owned()]
10378    }
10379
10380    #[cfg(unix)]
10381    #[test]
10382    fn disposable_tree_removal_does_not_open_a_dotnet_diagnostic_fifo() {
10383        use std::sync::mpsc;
10384
10385        let temporary = tempfile::tempdir().unwrap();
10386        let tree = temporary.path().join("attempt");
10387        let diagnostic = tree.join("tmp/clr-debug-pipe-runner-in");
10388        fs::create_dir_all(diagnostic.parent().unwrap()).unwrap();
10389        assert!(
10390            std::process::Command::new("mkfifo")
10391                .arg(&diagnostic)
10392                .status()
10393                .unwrap()
10394                .success()
10395        );
10396
10397        let (finished, result) = mpsc::channel();
10398        std::thread::spawn(move || {
10399            let removed = remove_runtime_tree(&tree);
10400            let _ = finished.send(removed);
10401        });
10402
10403        result
10404            .recv_timeout(Duration::from_secs(2))
10405            .expect("runtime deletion must not wait for a FIFO peer")
10406            .unwrap();
10407        assert!(!diagnostic.exists());
10408    }
10409
10410    /// The junction WinINet leaves in a job's profile, reproduced: its own DACL
10411    /// denies `Everyone` the right to list it.
10412    #[cfg(windows)]
10413    #[test]
10414    fn disposable_tree_removal_unlinks_a_junction_nobody_may_list() {
10415        let temporary = tempfile::tempdir().unwrap();
10416        let tree = temporary.path().join("attempt");
10417        let cache = tree.join(r"tmp\home\AppData\Local\Microsoft\Windows\INetCache");
10418        let outside = temporary.path().join("outside");
10419        fs::create_dir_all(&cache).unwrap();
10420        fs::create_dir_all(&outside).unwrap();
10421        fs::write(outside.join("keep.txt"), b"not the attempt's").unwrap();
10422        fs::write(tree.join("tmp").join("job.log"), b"job output").unwrap();
10423        let junction = cache.join("Content.IE5");
10424        let created = std::process::Command::new("cmd")
10425            .arg("/C")
10426            .arg("mklink")
10427            .arg("/J")
10428            .arg(&junction)
10429            .arg(&outside)
10430            .output()
10431            .unwrap();
10432        assert!(created.status.success(), "{created:?}");
10433        // `/L` puts the entry on the junction itself, as WinINet does, rather
10434        // than on the directory it points at. `*S-1-1-0` is `Everyone` in every
10435        // display language.
10436        let denied = std::process::Command::new("icacls")
10437            .arg(&junction)
10438            .args(["/L", "/deny", "*S-1-1-0:(RD)"])
10439            .output()
10440            .unwrap();
10441        assert!(denied.status.success(), "{denied:?}");
10442
10443        // The control: without the fix the tree cannot be removed at all.
10444        let unfixed = remove_dir_all::remove_dir_all(&tree)
10445            .expect_err("the fixture no longer reproduces the WinINet junction");
10446        assert_eq!(unfixed.kind(), std::io::ErrorKind::PermissionDenied);
10447        assert!(junction.symlink_metadata().is_ok());
10448
10449        remove_runtime_tree(&tree).expect("a tree holding a deny-list junction is removed");
10450        assert!(!tree.exists(), "the attempt tree survived");
10451        assert_eq!(
10452            fs::read(outside.join("keep.txt")).unwrap(),
10453            b"not the attempt's",
10454            "removing the junction followed it into the directory it points at"
10455        );
10456    }
10457
10458    /// One slot entry that refuses to be removed, and the undo that lets the
10459    /// temporary directory be torn down afterwards.
10460    ///
10461    /// The two operating systems refuse for different reasons and there is no
10462    /// portable third. Windows will not open a file for deletion while a handle
10463    /// with share mode zero is held on it; Unix will not unlink from a directory
10464    /// the caller cannot write. Both are states a real machine reaches -- a
10465    /// scanner holding a file open, a job that left a directory read-only -- so
10466    /// the injection is a filesystem fact rather than a seam cut into the
10467    /// product for a test to pull.
10468    ///
10469    /// The Unix half is a permission, and permissions do not apply to `root`.
10470    /// [`Self::inject`] proves the block on a throwaway directory before
10471    /// claiming it, so a suite running as `root` says it could not inject rather
10472    /// than asserting nothing and passing.
10473    struct BlockedDeletion {
10474        directory: PathBuf,
10475        #[cfg(windows)]
10476        _handle: fs::File,
10477    }
10478
10479    impl BlockedDeletion {
10480        const HELD: &'static str = "held-open";
10481
10482        /// Fill `directory` with a file that cannot be removed, or answer `None`
10483        /// when this account cannot be stopped from removing anything.
10484        fn inject(directory: &Path) -> Option<Self> {
10485            #[cfg(unix)]
10486            if !Self::refusal_is_possible() {
10487                return None;
10488            }
10489            fs::create_dir_all(directory).unwrap();
10490            fs::write(
10491                directory.join(Self::HELD),
10492                b"a file the scrub cannot remove",
10493            )
10494            .unwrap();
10495            #[cfg(windows)]
10496            let handle = {
10497                use std::os::windows::fs::OpenOptionsExt;
10498
10499                fs::OpenOptions::new()
10500                    .read(true)
10501                    .share_mode(0)
10502                    .open(directory.join(Self::HELD))
10503                    .expect("the blocking handle opens")
10504            };
10505            #[cfg(unix)]
10506            Self::set_mode(directory, 0o555);
10507            Some(Self {
10508                directory: directory.to_path_buf(),
10509                #[cfg(windows)]
10510                _handle: handle,
10511            })
10512        }
10513
10514        fn release(self) {
10515            drop(self);
10516        }
10517
10518        #[cfg(unix)]
10519        fn refusal_is_possible() -> bool {
10520            let probe = tempfile::tempdir().unwrap();
10521            let directory = probe.path().join("probe");
10522            fs::create_dir(&directory).unwrap();
10523            fs::write(directory.join("file"), b"probe").unwrap();
10524            Self::set_mode(&directory, 0o555);
10525            let refused = fs::remove_dir_all(&directory).is_err();
10526            Self::set_mode(&directory, 0o755);
10527            refused
10528        }
10529
10530        #[cfg(unix)]
10531        fn set_mode(directory: &Path, mode: u32) {
10532            use std::os::unix::fs::PermissionsExt;
10533
10534            let mut permissions = fs::metadata(directory).unwrap().permissions();
10535            permissions.set_mode(mode);
10536            fs::set_permissions(directory, permissions).unwrap();
10537        }
10538    }
10539
10540    impl Drop for BlockedDeletion {
10541        fn drop(&mut self) {
10542            #[cfg(unix)]
10543            Self::set_mode(&self.directory, 0o755);
10544            #[cfg(not(unix))]
10545            let _ = &self.directory;
10546        }
10547    }
10548
10549    #[tokio::test]
10550    async fn two_sequential_jobs_keep_the_checkout_and_start_without_the_earlier_runner_state() {
10551        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
10552            .with_persistent_workspace(1);
10553        harness.ready().await;
10554
10555        let first = harness.launch().await;
10556        let slot = harness.slot_path(1);
10557        assert_eq!(first.runtime_path(), slot);
10558        assert_eq!(
10559            read_runner_id(&slot),
10560            Some(73),
10561            "the attempt registered, so its identity is on disk"
10562        );
10563        let marker = retain_under_work(&slot);
10564        litter_the_slot(&slot);
10565
10566        harness.cleanup_retaining_work(first.id).await;
10567
10568        // The allowlist is exactly one entry, so this assertion is the security
10569        // property in full: what is retained, and that nothing else is.
10570        assert_eq!(entries_of(&slot), only_the_job_workspace());
10571        assert_eq!(fs::read_to_string(&marker).unwrap(), RETAINED);
10572        assert_eq!(
10573            read_runner_id(&slot),
10574            None,
10575            "the first attempt's registration identity is gone before the second starts"
10576        );
10577        assert_eq!(harness.attempt(first.id).state(), AttemptState::Cleaned);
10578        assert!(!harness.attempt(first.id).holds_slot_lease());
10579
10580        let second = harness.launch().await;
10581        assert_ne!(second.id, first.id);
10582        assert_eq!(second.workspace(), AttemptWorkspace::persistent_slot(nz(1)));
10583        assert_eq!(
10584            second.runtime_path(),
10585            slot,
10586            "the same slot, so the same retained `_work`"
10587        );
10588        assert_eq!(fs::read_to_string(&marker).unwrap(), RETAINED);
10589    }
10590
10591    #[tokio::test]
10592    async fn cleaning_a_persistent_slot_needs_no_policy_and_scans_no_directory_for_ownership() {
10593        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
10594            .with_persistent_workspace(1);
10595        harness.ready().await;
10596        let attempt = harness.launch().await;
10597        let slot = harness.slot_path(1);
10598        let marker = retain_under_work(&slot);
10599        litter_the_slot(&slot);
10600        harness.conclude(attempt.id);
10601
10602        // A repository removed from the product between the attempt concluding
10603        // and the sweep reaching it. The journalled runtime path and slot are
10604        // the only facts left, and `04-security-recovery.md` requires them to be
10605        // enough: the alternative is scanning a root to work out which
10606        // directories were ours, which invariant 6 forbids.
10607        harness
10608            .store
10609            .remove_policy(harness.policy.id, harness.policy.revision())
10610            .unwrap();
10611        assert!(harness.store.policy(harness.policy.id).unwrap().is_none());
10612
10613        harness
10614            .launcher
10615            .clean(attempt.id)
10616            .await
10617            .expect("journal facts alone are enough to clean the slot");
10618
10619        assert_eq!(entries_of(&slot), only_the_job_workspace());
10620        assert!(marker.exists());
10621        assert_eq!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
10622    }
10623
10624    #[tokio::test]
10625    async fn an_injected_partial_deletion_quarantines_the_slot_across_a_restart() {
10626        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
10627            .with_persistent_workspace(2);
10628        harness.ready().await;
10629        let first = harness.launch().await;
10630        let slot = harness.slot_path(1);
10631        let marker = retain_under_work(&slot);
10632        litter_the_slot(&slot);
10633        harness.conclude(first.id);
10634
10635        let Some(block) = BlockedDeletion::inject(&slot.join("bin")) else {
10636            eprintln!(
10637                "skipped: this account cannot be refused a deletion, so no partial deletion can \
10638                 be injected"
10639            );
10640            return;
10641        };
10642
10643        let refusal = harness
10644            .launcher
10645            .clean(first.id)
10646            .await
10647            .expect_err("a deletion that failed may not report a cleaned slot");
10648        let rendered = refusal.reason.to_string();
10649        assert!(rendered.contains("could not be removed"), "{rendered}");
10650
10651        let held = harness.attempt(first.id);
10652        assert_eq!(held.state(), AttemptState::Failed, "still not cleaned");
10653        assert!(held.holds_slot_lease(), "so the slot is still leased");
10654        assert!(
10655            !held.state().counts_against_capacity(),
10656            "and a concluded attempt still costs the host no capacity"
10657        );
10658
10659        // The same journal and the same directories, under a launcher that
10660        // remembers nothing. Recovery must complete: a host that can launch
10661        // nothing at all because one slot is stuck is not what "does not count
10662        // as active host capacity" means.
10663        let restarted = harness.restart();
10664        restarted
10665            .recover_startup(std::slice::from_ref(&harness.policy))
10666            .await
10667            .expect("one quarantined slot does not stop the host recovering");
10668        assert_eq!(
10669            harness.attempt(first.id).state(),
10670            AttemptState::Failed,
10671            "the quarantine survived the restart"
10672        );
10673        assert!(
10674            harness
10675                .reconcile_events
10676                .events()
10677                .iter()
10678                .any(|event| matches!(
10679                    event,
10680                    LifecycleEvent::AttemptCleanFailed {
10681                        reason: "slot_entry_could_not_be_removed",
10682                        ..
10683                    }
10684                )),
10685            "the refusal is reported rather than retried in silence"
10686        );
10687
10688        // Capacity two, slot one quarantined: the next attempt goes to s2 and
10689        // never to the slot still holding runner state.
10690        let second = harness
10691            .launch_through(&restarted)
10692            .await
10693            .expect("the host can still launch");
10694        assert_eq!(second.workspace(), AttemptWorkspace::persistent_slot(nz(2)));
10695
10696        // And the same cleanup succeeds once the obstruction is gone, which is
10697        // what "retry through normal recovery" has to mean.
10698        block.release();
10699        restarted
10700            .clean(first.id)
10701            .await
10702            .expect("the retried cleanup completes");
10703        assert_eq!(entries_of(&slot), only_the_job_workspace());
10704        assert!(marker.exists());
10705        assert_eq!(harness.attempt(first.id).state(), AttemptState::Cleaned);
10706    }
10707
10708    /// The disposable half of the same injection, which had no test of its own.
10709    ///
10710    /// `scrub_workspace`'s ephemeral arm turns a failed `remove_dir_all` into
10711    /// `"attempt workspace could not be removed"`, and until now that branch was
10712    /// only reachable in theory: every injected-deletion test drove a persistent
10713    /// slot. The property is the same one and matters for the same reason --
10714    /// `04-security-recovery.md`'s "Cleanup partly fails and the slot is reused
10715    /// anyway" -- but the disposable guarantee is stronger, so a cleanup that
10716    /// reported success over a directory it had not removed would be the
10717    /// contamination gate failing silently rather than a slot being held.
10718    #[tokio::test]
10719    async fn an_injected_deletion_failure_leaves_a_disposable_attempt_uncleaned() {
10720        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
10721        harness.ready().await;
10722        let attempt = harness.launch().await;
10723        let runtime = attempt.runtime_path().to_path_buf();
10724        assert_eq!(attempt.workspace(), AttemptWorkspace::Ephemeral);
10725        harness.conclude(attempt.id);
10726
10727        let Some(block) = BlockedDeletion::inject(&runtime.join("held-open-subdirectory")) else {
10728            eprintln!(
10729                "skipped: this account cannot be refused a deletion, so no partial deletion can be injected"
10730            );
10731            return;
10732        };
10733
10734        let refusal = harness
10735            .launcher
10736            .clean(attempt.id)
10737            .await
10738            .expect_err("a deletion that failed may not report a removed workspace");
10739        let rendered = refusal.reason.to_string();
10740        assert!(
10741            rendered.contains("could not be removed"),
10742            "the refusal names what happened: {rendered}"
10743        );
10744        assert_ne!(
10745            harness.attempt(attempt.id).state(),
10746            AttemptState::Cleaned,
10747            "an attempt whose directory is still on disk is not cleaned"
10748        );
10749        assert!(
10750            runtime.is_dir(),
10751            "the directory the removal could not finish is still there, which is the fact the journal must keep agreeing with"
10752        );
10753
10754        let restarted = harness.restart();
10755        restarted
10756            .recover_startup(std::slice::from_ref(&harness.policy))
10757            .await
10758            .expect("one locked ephemeral workspace does not stop startup recovery");
10759        assert!(
10760            harness
10761                .reconcile_events
10762                .events()
10763                .iter()
10764                .any(|event| matches!(
10765                    event,
10766                    LifecycleEvent::AttemptCleanFailed {
10767                        attempt: failed_attempt,
10768                        reason: "ephemeral_workspace_could_not_be_removed",
10769                        ..
10770                    } if *failed_attempt == attempt.id
10771                )),
10772            "the deferred cleanup remains visible while the daemon keeps running"
10773        );
10774
10775        // And the ordinary retry -- the reconciler's next terminal sweep --
10776        // finishes it once the obstruction is gone.
10777        block.release();
10778        harness
10779            .launcher
10780            .clean(attempt.id)
10781            .await
10782            .expect("the retried cleanup completes");
10783        assert!(!runtime.exists(), "the whole attempt directory goes");
10784        assert_eq!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
10785    }
10786
10787    /// Supervision retries a workspace it cannot remove with per-attempt
10788    /// backoff, so a stuck workspace is reported once per delay rather than on
10789    /// every pass.
10790    #[tokio::test]
10791    async fn supervision_backs_off_a_workspace_it_cannot_remove() {
10792        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
10793        harness.ready().await;
10794        let attempt = harness.launch().await;
10795        let runtime = attempt.runtime_path().to_path_buf();
10796        harness.conclude(attempt.id);
10797        let Some(block) = BlockedDeletion::inject(&runtime.join("held-open-subdirectory")) else {
10798            eprintln!(
10799                "skipped: this account cannot be refused a deletion, so no partial deletion can be injected"
10800            );
10801            return;
10802        };
10803        let failures = || {
10804            harness
10805                .reconcile_events
10806                .events()
10807                .iter()
10808                .filter(|event| {
10809                    matches!(
10810                        event,
10811                        LifecycleEvent::AttemptCleanFailed { attempt: failed, .. }
10812                            if *failed == attempt.id
10813                    )
10814                })
10815                .count()
10816        };
10817        let supervise = async || {
10818            harness
10819                .launcher
10820                .supervise(&harness.policy)
10821                .await
10822                .expect("a stuck workspace does not fail supervision");
10823        };
10824
10825        supervise().await;
10826        assert_eq!(failures(), 1);
10827        supervise().await;
10828        assert_eq!(
10829            failures(),
10830            1,
10831            "the failed cleanup was retried on the very next pass"
10832        );
10833        harness.clock.advance_secs(31);
10834        supervise().await;
10835        assert_eq!(
10836            failures(),
10837            2,
10838            "the cleanup was not retried once its delay passed"
10839        );
10840
10841        block.release();
10842        harness.clock.advance_secs(61);
10843        supervise().await;
10844        assert!(
10845            !runtime.exists(),
10846            "the retry removes the workspace once it can"
10847        );
10848        assert_eq!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
10849    }
10850
10851    #[tokio::test]
10852    async fn changing_a_repository_back_to_ephemeral_leaves_every_old_slot_untouched() {
10853        let mut harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
10854            .with_persistent_workspace(1);
10855        harness.ready().await;
10856        let first = harness.launch().await;
10857        let slot = harness.slot_path(1);
10858        let marker = retain_under_work(&slot);
10859        harness.cleanup_retaining_work(first.id).await;
10860
10861        // Every attempt for this policy is cleaned, so the mutation is allowed
10862        // (`04-security-recovery.md`, "Recovery rules"). What it must not do is
10863        // move or delete anything the operator still owns.
10864        harness
10865            .policy
10866            .set_workspace_policy(WorkspacePolicy::Ephemeral)
10867            .unwrap();
10868
10869        let second = harness.launch().await;
10870        assert_eq!(second.workspace(), AttemptWorkspace::Ephemeral);
10871        assert_eq!(
10872            second.runtime_path().parent().unwrap(),
10873            harness.host_root(),
10874            "a disposable attempt is a child of the host root"
10875        );
10876        assert!(slot.is_dir(), "the old slot is left where it stands");
10877        assert_eq!(fs::read_to_string(&marker).unwrap(), RETAINED);
10878
10879        // And cleaning the disposable attempt removes its own directory whole
10880        // without reaching the retained slot beside it.
10881        harness.conclude(second.id);
10882        harness.launcher.clean(second.id).await.unwrap();
10883        assert!(!second.runtime_path().exists());
10884        assert!(marker.exists());
10885    }
10886
10887    #[tokio::test]
10888    async fn a_persistent_slot_is_scrubbed_only_after_the_process_is_signalled_and_gone() {
10889        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
10890            .with_persistent_workspace(1);
10891        harness.ready().await;
10892        let slot = harness.slot_path(1);
10893        fs::create_dir_all(&slot).unwrap();
10894        let marker = retain_under_work(&slot);
10895        litter_the_slot(&slot);
10896
10897        let id = AttemptId::new_random();
10898        let mut attempt = RunnerAttempt::allocate_in(
10899            id,
10900            harness.policy.id,
10901            &slot,
10902            AttemptWorkspace::persistent_slot(nz(1)),
10903            harness.clock.now(),
10904        );
10905        attempt.jit_received(harness.clock.now()).unwrap();
10906        attempt.started(4242, harness.clock.now()).unwrap();
10907        harness.store.record_attempt(&attempt).unwrap();
10908        harness.clock.advance_secs(11);
10909        harness.processes.set_alive(true);
10910        harness
10911            .github
10912            .observe(GithubRunnerObservation::NotRegistered);
10913
10914        harness.launcher.supervise(&harness.policy).await.unwrap();
10915
10916        // The identity and termination ordering `e3` established is unchanged by
10917        // the workspace kind: the intent is durable before the signal, and the
10918        // slot is scrubbed only once the process is gone.
10919        let actions = harness.processes.actions.lock().unwrap().clone();
10920        let intent = actions
10921            .iter()
10922            .position(|action| *action == "terminate_intent")
10923            .unwrap();
10924        let signal = actions
10925            .iter()
10926            .position(|action| *action == "terminate")
10927            .unwrap();
10928        assert!(intent < signal, "{actions:?}");
10929        assert!(!harness.processes.alive.load(Ordering::SeqCst));
10930
10931        let cleaned = harness.attempt(id);
10932        assert_eq!(cleaned.state(), AttemptState::Cleaned);
10933        assert!(matches!(
10934            cleaned.outcome(),
10935            Some(AttemptOutcome::Failed {
10936                reason: FailureReason::TerminatedAfterRegistrationTimeout
10937            })
10938        ));
10939        assert_eq!(entries_of(&slot), only_the_job_workspace());
10940        assert!(marker.exists());
10941    }
10942
10943    #[test]
10944    fn a_scrub_retains_one_real_work_directory_and_removes_every_other_entry() {
10945        let root = tempfile::tempdir().unwrap();
10946        let slot = root.path().join("s1");
10947        fs::create_dir(&slot).unwrap();
10948        let marker = retain_under_work(&slot);
10949        litter_the_slot(&slot);
10950        fs::write(slot.join("runner-package"), b"verified").unwrap();
10951
10952        scrub_slot_entries(&slot).expect("a slot of ordinary runner state scrubs");
10953        verify_slot_scrubbed(&slot).expect("and proves it afterwards");
10954
10955        assert_eq!(entries_of(&slot), only_the_job_workspace());
10956        assert!(marker.exists());
10957    }
10958
10959    #[test]
10960    fn a_residue_refusal_never_reports_the_under_count_as_the_fact() {
10961        let slot = Path::new("/runners/s1");
10962
10963        // The ordinary case: the listing counted, so the count is the fact and
10964        // the published names qualify it.
10965        let counted = residue_detail(slot, 2, &["`bin`".to_owned()]);
10966        assert!(counted.contains("2 entries other than"), "{counted}");
10967        assert!(counted.contains("including `bin`"), "{counted}");
10968        assert_eq!(
10969            residue_detail(slot, 1, &[]),
10970            format!(
10971                "1 entry other than `{DEFAULT_WORK_FOLDER}` survived cleanup of {}",
10972                slot.display()
10973            )
10974        );
10975
10976        // The race the second pass exists for: the listing saw nothing and the
10977        // filesystem answered otherwise. Saying "0 entries survived" here would
10978        // state the under-count as the fact and contradict the rest of the
10979        // sentence.
10980        let raced = residue_detail(slot, 0, &["`.credentials`".to_owned()]);
10981        assert!(!raced.contains('0'), "{raced}");
10982        assert!(raced.contains("reported nothing but"), "{raced}");
10983        assert!(raced.contains("`.credentials` survived cleanup"), "{raced}");
10984    }
10985
10986    #[test]
10987    fn verification_asks_the_filesystem_rather_than_the_listing_that_missed_an_entry() {
10988        let root = tempfile::tempdir().unwrap();
10989        let slot = root.path().join("s1");
10990        fs::create_dir(&slot).unwrap();
10991        fs::create_dir(slot.join(DEFAULT_WORK_FOLDER)).unwrap();
10992        verify_slot_scrubbed(&slot).expect("only `_work` is a clean slot");
10993
10994        // Runner binaries, registration identity, process identity and this
10995        // agent's lifecycle marks, one at a time, so a scrub that skipped
10996        // exactly one is still caught.
10997        for survivor in ["bin", ".credentials", IDENTITY_FILE, RUNNER_ID_FILE] {
10998            fs::write(slot.join(survivor), b"left behind").unwrap();
10999            let quarantine = verify_slot_scrubbed(&slot).unwrap_err();
11000            assert_eq!(quarantine.refusal, SlotRefusal::Residue);
11001            assert!(
11002                quarantine.detail.contains(&format!("`{survivor}`")),
11003                "{quarantine}"
11004            );
11005            fs::remove_file(slot.join(survivor)).unwrap();
11006        }
11007
11008        // A handoff is named by its published prefix, never by the UUID that
11009        // follows it, and never by the payload it holds.
11010        let handoff = slot.join(format!("{}whatever.tmp", RestrictiveHandoff::NAME_PREFIX));
11011        fs::write(&handoff, JIT.as_bytes()).unwrap();
11012        let quarantine = verify_slot_scrubbed(&slot).unwrap_err();
11013        assert!(
11014            quarantine.detail.contains("an encoded JIT handoff"),
11015            "{quarantine}"
11016        );
11017        assert!(!quarantine.detail.contains(JIT), "{quarantine}");
11018        fs::remove_file(&handoff).unwrap();
11019
11020        // A name a workflow chose is counted and never echoed: a slot root is
11021        // writable by the job, so a file named after a secret would be published
11022        // by any message that repeated the listing.
11023        fs::write(slot.join("ghp_DO_NOT_LEAK"), b"named by the job").unwrap();
11024        let quarantine = verify_slot_scrubbed(&slot).unwrap_err();
11025        assert!(
11026            quarantine.detail.contains("1 entry other than"),
11027            "{quarantine}"
11028        );
11029        assert!(
11030            !quarantine.detail.contains("ghp_DO_NOT_LEAK"),
11031            "{quarantine}"
11032        );
11033    }
11034
11035    #[test]
11036    fn a_slot_is_derived_from_the_journal_and_refused_when_it_disagrees() {
11037        let root = tempfile::tempdir().unwrap();
11038        let configured =
11039            LocalAbsolutePath::new(root.path().to_str().unwrap()).expect("a local absolute root");
11040        let slot = configured.as_path().join("s1");
11041        fs::create_dir(&slot).unwrap();
11042
11043        verify_journalled_slot(&slot, nz(1), Some(&configured))
11044            .expect("the journalled slot agrees");
11045        verify_journalled_slot(&slot, nz(1), None)
11046            .expect("and a policy that is gone removes a check, not the ability to clean");
11047
11048        // The journalled slot number is what names the directory. `s1` recorded
11049        // as slot two is corrupt state, not a slot to clean.
11050        assert_eq!(
11051            verify_journalled_slot(&slot, nz(2), None)
11052                .unwrap_err()
11053                .refusal,
11054            SlotRefusal::NotTheJournalledSlot
11055        );
11056        for stray in ["s1/nested", "not-a-slot", "s01"] {
11057            let path = configured.as_path().join(stray);
11058            assert_eq!(
11059                verify_journalled_slot(&path, nz(1), None)
11060                    .unwrap_err()
11061                    .refusal,
11062                SlotRefusal::NotTheJournalledSlot,
11063                "{}",
11064                path.display()
11065            );
11066        }
11067
11068        // A surviving policy that names a different root does not get to have
11069        // its disagreement resolved by deleting something.
11070        let elsewhere = tempfile::tempdir().unwrap();
11071        let other =
11072            LocalAbsolutePath::new(elsewhere.path().to_str().unwrap()).expect("a second root");
11073        assert_eq!(
11074            verify_journalled_slot(&slot, nz(1), Some(&other))
11075                .unwrap_err()
11076                .refusal,
11077            SlotRefusal::PolicyRootDisagrees
11078        );
11079    }
11080
11081    #[cfg(unix)]
11082    #[test]
11083    fn a_substituted_work_directory_quarantines_the_slot_and_deletes_nothing_outside_it() {
11084        // Windows needs a privilege to create a junction or a symlink, so the
11085        // substitution is made here; the rule is platform-independent because it
11086        // is `symlink_metadata`'s answer plus the reparse attribute.
11087        let root = tempfile::tempdir().unwrap();
11088        let outside = root.path().join("operator-data");
11089        fs::create_dir(&outside).unwrap();
11090        let sentinel = outside.join("do-not-delete.txt");
11091        fs::write(
11092            &sentinel,
11093            b"an operator's data, outside every approved root",
11094        )
11095        .unwrap();
11096
11097        let slot = root.path().join("s1");
11098        fs::create_dir(&slot).unwrap();
11099        fs::create_dir(slot.join("bin")).unwrap();
11100        std::os::unix::fs::symlink(&outside, slot.join(DEFAULT_WORK_FOLDER)).unwrap();
11101
11102        let quarantine = scrub_slot_entries(&slot).unwrap_err();
11103        assert_eq!(quarantine.refusal, SlotRefusal::WorkNotADirectory);
11104        assert!(
11105            sentinel.exists(),
11106            "the deletion followed the link out of the slot"
11107        );
11108        assert!(outside.is_dir());
11109        assert!(
11110            slot.join(DEFAULT_WORK_FOLDER).symlink_metadata().is_ok(),
11111            "the substituted link is left for the operator, never unlinked as if it were ours"
11112        );
11113
11114        // A `_work` that is a plain file is the same refusal for the same
11115        // reason: it is not a job workspace, and this is not the code that
11116        // decides what to do about it.
11117        let file_work = root.path().join("s2");
11118        fs::create_dir(&file_work).unwrap();
11119        fs::write(file_work.join(DEFAULT_WORK_FOLDER), b"not a directory").unwrap();
11120        assert_eq!(
11121            scrub_slot_entries(&file_work).unwrap_err().refusal,
11122            SlotRefusal::WorkNotADirectory
11123        );
11124    }
11125
11126    #[cfg(unix)]
11127    #[test]
11128    fn a_slot_replaced_by_a_link_out_of_its_root_is_refused_before_anything_is_read() {
11129        let root = tempfile::tempdir().unwrap();
11130        let outside = root.path().join("operator-data");
11131        fs::create_dir(&outside).unwrap();
11132        let sentinel = outside.join("do-not-delete.txt");
11133        fs::write(
11134            &sentinel,
11135            b"an operator's data, outside every approved root",
11136        )
11137        .unwrap();
11138
11139        // The lexical half of containment passes -- the name is right and the
11140        // parent is right -- and canonical resolution is what catches it.
11141        let inside = root.path().join("inside");
11142        fs::create_dir(&inside).unwrap();
11143        let slot = inside.join("s1");
11144        std::os::unix::fs::symlink(&outside, &slot).unwrap();
11145
11146        assert_eq!(
11147            verify_journalled_slot(&slot, nz(1), None)
11148                .unwrap_err()
11149                .refusal,
11150            SlotRefusal::Containment
11151        );
11152        assert!(sentinel.exists());
11153        assert!(
11154            slot.symlink_metadata().is_ok(),
11155            "the link is left for the operator rather than removed as if it were ours"
11156        );
11157    }
11158
11159    /// The Windows half of the case above.
11160    ///
11161    /// Containment was proven only on Unix, and a symbolic link is the wrong
11162    /// instrument to prove it with on Windows: creating one needs a privilege an
11163    /// ordinary workflow does not have, so it is not the substitution an
11164    /// attacker would reach for. A **junction** needs none, which makes it both
11165    /// the realistic attack and the one this repository must refuse -- and
11166    /// `04-security-recovery.md` names it in the same breath as the symlink for
11167    /// exactly that reason.
11168    #[cfg(windows)]
11169    #[test]
11170    fn a_slot_root_replaced_by_a_junction_is_refused_before_anything_is_read() {
11171        let root = tempfile::tempdir().unwrap();
11172        let outside = root.path().join("operator-data");
11173        fs::create_dir(&outside).unwrap();
11174        let sentinel = outside.join("do-not-delete.txt");
11175        fs::write(
11176            &sentinel,
11177            b"an operator's data, outside every approved root",
11178        )
11179        .unwrap();
11180
11181        // The lexical half of containment passes -- `s1` under the root the
11182        // journal names -- and canonical resolution is what catches it.
11183        let inside = root.path().join("inside");
11184        fs::create_dir(&inside).unwrap();
11185        let slot = inside.join("s1");
11186        let Some(()) = plant_junction(&slot, &outside) else {
11187            eprintln!("skipped: this machine would not create a directory junction");
11188            return;
11189        };
11190
11191        assert_eq!(
11192            verify_journalled_slot(&slot, nz(1), None)
11193                .unwrap_err()
11194                .refusal,
11195            SlotRefusal::Containment
11196        );
11197        assert!(
11198            sentinel.exists(),
11199            "the refusal resolved the junction and reached the operator's data"
11200        );
11201        assert!(
11202            slot.symlink_metadata().is_ok(),
11203            "the junction is left for the operator rather than removed as if it were ours"
11204        );
11205    }
11206
11207    /// Plant a directory junction at `link` pointing at `target`.
11208    ///
11209    /// A junction is the Windows substitution this has to refuse, and unlike a
11210    /// symbolic link it needs no privilege — which is exactly why it is the one
11211    /// an unprivileged workflow would reach for. `mklink` is a `cmd` builtin, so
11212    /// there is no binary to find and nothing to install; `None` means this
11213    /// machine would not make one and the caller says so rather than asserting
11214    /// nothing.
11215    #[cfg(windows)]
11216    fn plant_junction(link: &Path, target: &Path) -> Option<()> {
11217        let made = std::process::Command::new("cmd")
11218            .arg("/C")
11219            .arg("mklink")
11220            .arg("/J")
11221            .arg(link)
11222            .arg(target)
11223            .output()
11224            .ok()?;
11225        (made.status.success() && link.symlink_metadata().is_ok()).then_some(())
11226    }
11227
11228    /// The dependency caches live beside attempts, under `<runner root>/_cache`,
11229    /// and jobs link into them (pnpm hard-links from its store; a workflow may
11230    /// symlink or junction a cache into its workspace). Removing an ephemeral
11231    /// attempt must take the links and never what they point at.
11232    #[test]
11233    fn removing_an_attempt_never_reaches_into_the_dependency_caches_it_linked() {
11234        let root = tempfile::tempdir().unwrap();
11235        let cache = root.path().join("_cache").join("o").join("r").join("npm");
11236        fs::create_dir_all(&cache).unwrap();
11237        let cached = cache.join("package.tgz");
11238        fs::write(&cached, b"kept between jobs").unwrap();
11239        let attempt = root.path().join("0123456789abcdef");
11240        let work = attempt.join("_work").join("repo");
11241        fs::create_dir_all(&work).unwrap();
11242        fs::hard_link(&cached, work.join("hard-linked.tgz")).unwrap();
11243        #[cfg(unix)]
11244        std::os::unix::fs::symlink(&cache, work.join("linked-cache")).unwrap();
11245        #[cfg(windows)]
11246        if plant_junction(&work.join("linked-cache"), &cache).is_none() {
11247            eprintln!("this machine would not create a junction; the hard link is still checked");
11248        }
11249
11250        remove_runtime_tree(&attempt).unwrap();
11251        assert!(!attempt.exists());
11252        assert_eq!(fs::read(&cached).unwrap(), b"kept between jobs");
11253    }
11254
11255    #[cfg(windows)]
11256    #[test]
11257    fn a_work_directory_replaced_by_a_junction_fails_closed_and_deletes_nothing_beyond_it() {
11258        let root = tempfile::tempdir().unwrap();
11259        let outside = root.path().join("operator-data");
11260        fs::create_dir(&outside).unwrap();
11261        let sentinel = outside.join("do-not-delete.txt");
11262        fs::write(
11263            &sentinel,
11264            b"an operator's data, outside every approved root",
11265        )
11266        .unwrap();
11267
11268        let slot = root.path().join("s1");
11269        fs::create_dir(&slot).unwrap();
11270        fs::create_dir(slot.join("bin")).unwrap();
11271        let Some(()) = plant_junction(&slot.join(DEFAULT_WORK_FOLDER), &outside) else {
11272            eprintln!("skipped: this machine would not create a directory junction");
11273            return;
11274        };
11275
11276        // The reparse point is what `is_link_like` answers on, so a junction is
11277        // refused for the same reason a symbolic link is and neither is
11278        // descended into.
11279        let work = fs::symlink_metadata(slot.join(DEFAULT_WORK_FOLDER)).unwrap();
11280        assert!(is_link_like(&work), "a junction is a reparse point");
11281        let quarantine = scrub_slot_entries(&slot).unwrap_err();
11282        assert_eq!(quarantine.refusal, SlotRefusal::WorkNotADirectory);
11283        assert!(
11284            sentinel.exists(),
11285            "the deletion followed the junction out of the slot"
11286        );
11287        assert!(outside.is_dir());
11288
11289        // A junction standing where an ordinary entry was is unlinked rather
11290        // than followed, so the removal still cannot reach through it.
11291        let elsewhere = root.path().join("s2");
11292        fs::create_dir(&elsewhere).unwrap();
11293        fs::create_dir(elsewhere.join(DEFAULT_WORK_FOLDER)).unwrap();
11294        if plant_junction(&elsewhere.join("externals"), &outside).is_some() {
11295            scrub_slot_entries(&elsewhere).expect("an ordinary entry is removed, junction or not");
11296            verify_slot_scrubbed(&elsewhere).expect("and the slot verifies");
11297            assert!(sentinel.exists(), "the junction was followed, not unlinked");
11298            assert_eq!(entries_of(&elsewhere), only_the_job_workspace());
11299        }
11300    }
11301
11302    #[cfg(unix)]
11303    #[tokio::test]
11304    async fn a_substituted_work_directory_leaves_the_attempt_uncleaned_and_still_leased() {
11305        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
11306            .with_persistent_workspace(2);
11307        harness.ready().await;
11308        let first = harness.launch().await;
11309        let slot = harness.slot_path(1);
11310        harness.conclude(first.id);
11311
11312        let outside = harness._root.path().join("operator-data");
11313        fs::create_dir_all(&outside).unwrap();
11314        let sentinel = outside.join("do-not-delete.txt");
11315        fs::write(&sentinel, b"outside every approved root").unwrap();
11316        std::os::unix::fs::symlink(&outside, slot.join(DEFAULT_WORK_FOLDER)).unwrap();
11317
11318        harness
11319            .launcher
11320            .clean(first.id)
11321            .await
11322            .expect_err("a slot whose `_work` was substituted is quarantined");
11323        assert!(sentinel.exists());
11324
11325        let held = harness.attempt(first.id);
11326        assert_eq!(held.state(), AttemptState::Failed);
11327        assert!(held.holds_slot_lease());
11328
11329        // The quarantined slot is not silently chosen again.
11330        let second = harness.launch().await;
11331        assert_eq!(second.workspace(), AttemptWorkspace::persistent_slot(nz(2)));
11332    }
11333
11334    #[test]
11335    fn a_slot_that_is_a_file_is_refused_and_a_slot_that_is_gone_is_not() {
11336        let root = tempfile::tempdir().unwrap();
11337        let occupied = root.path().join("s1");
11338        fs::write(&occupied, b"an operator's file").unwrap();
11339        // The journal check passes -- it is the right name under the right root
11340        // -- and the shape check is what refuses.
11341        verify_journalled_slot(&occupied, nz(1), None).expect("the path is the journalled slot");
11342        assert_eq!(
11343            slot_is_present(&occupied).unwrap_err().refusal,
11344            SlotRefusal::SlotNotADirectory
11345        );
11346        assert_eq!(fs::read_to_string(&occupied).unwrap(), "an operator's file");
11347
11348        // A directory that is simply not there leaves nothing to remove and
11349        // nothing to prove absent, so it is not a refusal.
11350        assert!(!slot_is_present(&root.path().join("s2")).unwrap());
11351        let present = root.path().join("s3");
11352        fs::create_dir(&present).unwrap();
11353        assert!(slot_is_present(&present).unwrap());
11354    }
11355
11356    #[test]
11357    fn cleanup_dispatches_on_the_journalled_kind_and_not_on_what_the_directory_holds() {
11358        let root = tempfile::tempdir().unwrap();
11359
11360        // A disposable directory that happens to contain a `_work` still goes
11361        // whole: the workspace kind is immutable so that the shape of a
11362        // directory a workflow can write to cannot choose its own algorithm.
11363        let disposable = root.path().join("abcdef012345");
11364        fs::create_dir_all(disposable.join(DEFAULT_WORK_FOLDER).join("repo")).unwrap();
11365        let ephemeral = RunnerAttempt::allocate(
11366            AttemptId::new_random(),
11367            fixtures::POLICY_ID,
11368            &disposable,
11369            fixtures::created_at(),
11370        );
11371        remove_materialized_package(&ephemeral).unwrap();
11372        assert!(!disposable.exists());
11373
11374        // And a slot keeps its `_work` with the same contents beneath it.
11375        let slot = root.path().join("s1");
11376        fs::create_dir_all(slot.join(DEFAULT_WORK_FOLDER).join("repo")).unwrap();
11377        fs::create_dir_all(slot.join("bin")).unwrap();
11378        let persistent = RunnerAttempt::allocate_in(
11379            AttemptId::new_random(),
11380            fixtures::POLICY_ID,
11381            &slot,
11382            AttemptWorkspace::persistent_slot(nz(1)),
11383            fixtures::created_at(),
11384        );
11385        remove_materialized_package(&persistent).unwrap();
11386        assert_eq!(entries_of(&slot), only_the_job_workspace());
11387        assert!(slot.join(DEFAULT_WORK_FOLDER).join("repo").is_dir());
11388    }
11389
11390    #[test]
11391    fn every_slot_refusal_names_a_distinct_event_class_and_keeps_the_lease() {
11392        let refusals = [
11393            SlotRefusal::NotTheJournalledSlot,
11394            SlotRefusal::PolicyRootDisagrees,
11395            SlotRefusal::Containment,
11396            SlotRefusal::SlotNotADirectory,
11397            SlotRefusal::Enumeration,
11398            SlotRefusal::WorkNotADirectory,
11399            SlotRefusal::Deletion,
11400            SlotRefusal::Residue,
11401        ];
11402        let classes: BTreeSet<&str> = refusals.iter().map(|refusal| refusal.class()).collect();
11403        assert_eq!(
11404            classes.len(),
11405            refusals.len(),
11406            "an event class shared by two refusals tells an operator less than it appears to"
11407        );
11408        for refusal in refusals {
11409            // The event field is a closed vocabulary, so it has to look like
11410            // one: `d1`'s sink allows the name verbatim.
11411            assert!(
11412                refusal
11413                    .class()
11414                    .chars()
11415                    .all(|c| c.is_ascii_lowercase() || c == '_'),
11416                "{}",
11417                refusal.class()
11418            );
11419            assert!(
11420                refusal.remediation().contains("slot lease"),
11421                "every refusal has to say the lease is still held: {}",
11422                refusal.class()
11423            );
11424        }
11425    }
11426
11427    #[test]
11428    fn copy_package_tree_copies_files_and_preserves_paths_with_spaces() {
11429        let root = tempfile::tempdir().unwrap();
11430        let source = root.path().join("source with spaces");
11431        let dest = root.path().join("dest with spaces");
11432
11433        fs::create_dir_all(&source).unwrap();
11434        fs::write(source.join("file1.txt"), b"hello").unwrap();
11435
11436        let nested = source.join("nested dir");
11437        fs::create_dir_all(&nested).unwrap();
11438        fs::write(nested.join("file2.txt"), b"world").unwrap();
11439
11440        // This is not a top-level `_work`, so it should be allowed
11441        let nested_work = nested.join(DEFAULT_WORK_FOLDER);
11442        fs::create_dir_all(&nested_work).unwrap();
11443        fs::write(nested_work.join("allowed.txt"), b"allowed").unwrap();
11444
11445        copy_package_tree(&source, &dest, &TrustedTrees::default()).unwrap();
11446
11447        assert_eq!(fs::read_to_string(dest.join("file1.txt")).unwrap(), "hello");
11448        assert_eq!(
11449            fs::read_to_string(dest.join("nested dir").join("file2.txt")).unwrap(),
11450            "world"
11451        );
11452        assert_eq!(
11453            fs::read_to_string(
11454                dest.join("nested dir")
11455                    .join(DEFAULT_WORK_FOLDER)
11456                    .join("allowed.txt")
11457            )
11458            .unwrap(),
11459            "allowed"
11460        );
11461    }
11462
11463    #[test]
11464    fn every_field_name_the_attempt_sink_emits_is_allowed() {
11465        use runner_manager_platform::logging::is_field_allowed;
11466
11467        // Kept beside the sink rather than derived from it, as in `reconcile`.
11468        for field in [
11469            "event",
11470            "attempt_id",
11471            "reason",
11472            "retry_in_ms",
11473            "duration_ms",
11474            "order_wait_ms",
11475            "lock_wait_ms",
11476            "turn_wait_ms",
11477            "materialize_ms",
11478            "package_wait_ms",
11479            "package_install_ms",
11480            "package_verify_ms",
11481            "package_copy_ms",
11482            "package_downloaded",
11483            "prepare_ms",
11484            "register_ms",
11485            "spawn_ms",
11486        ] {
11487            assert!(
11488                is_field_allowed(field),
11489                "`{field}` would be logged as `[redacted]`"
11490            );
11491        }
11492    }
11493
11494    #[cfg(unix)]
11495    fn package_entry(root: &Path, version: &str, manifest: &str) -> PathBuf {
11496        let entry = root.join("packages").join(version);
11497        fs::create_dir_all(entry.join("bin")).unwrap();
11498        fs::write(entry.join(crate::package::MANIFEST_FILE), manifest).unwrap();
11499        fs::write(entry.join("bin").join("Runner.Listener"), version).unwrap();
11500        entry
11501    }
11502
11503    /// The seed's binary's inode: a rebuilt seed holds a new file.
11504    #[cfg(unix)]
11505    fn listener_inode(seed: &Path) -> u64 {
11506        use std::os::unix::fs::MetadataExt as _;
11507        fs::metadata(seed.join("bin").join("Runner.Listener"))
11508            .unwrap()
11509            .ino()
11510    }
11511
11512    #[cfg(unix)]
11513    #[test]
11514    fn a_package_seed_is_built_once_and_reused_while_untouched() {
11515        let root = tempfile::tempdir().unwrap();
11516        let source = package_entry(root.path(), "2.336.0", "{\"digest\":\"a\"}");
11517        let seeds = root.path().join("runners").join(PACKAGE_SEED_DIR);
11518        let trusted = TrustedTrees::default();
11519
11520        let seed = refresh_package_seed(&source, &seeds, &trusted).unwrap();
11521        assert_eq!(seed, seeds.join("2.336.0"));
11522        assert_eq!(
11523            fs::read_to_string(seed.join("bin").join("Runner.Listener")).unwrap(),
11524            "2.336.0"
11525        );
11526
11527        let built = listener_inode(&seed);
11528        assert_eq!(
11529            refresh_package_seed(&source, &seeds, &trusted).unwrap(),
11530            seed
11531        );
11532        assert_eq!(listener_inode(&seed), built, "an untouched seed is reused");
11533    }
11534
11535    #[cfg(unix)]
11536    #[test]
11537    fn a_tampered_or_unrecorded_seed_is_rebuilt() {
11538        let root = tempfile::tempdir().unwrap();
11539        let source = package_entry(root.path(), "2.336.0", "{\"digest\":\"a\"}");
11540        let seeds = root.path().join("runners").join(PACKAGE_SEED_DIR);
11541        let trusted = TrustedTrees::default();
11542        let seed = refresh_package_seed(&source, &seeds, &trusted).unwrap();
11543
11544        // A job rewrites the binary in place and leaves the manifest alone.
11545        fs::write(seed.join("bin").join("Runner.Listener"), b"planted").unwrap();
11546        refresh_package_seed(&source, &seeds, &trusted).unwrap();
11547        assert_eq!(
11548            fs::read_to_string(seed.join("bin").join("Runner.Listener")).unwrap(),
11549            "2.336.0",
11550            "a seed whose print moved is rebuilt from the cache entry"
11551        );
11552
11553        // A restarted daemon has no print of a seed it did not build.
11554        // Keep the old inode allocated until the replacement is complete;
11555        // otherwise the filesystem may immediately reuse its number.
11556        let old_listener = fs::File::open(seed.join("bin").join("Runner.Listener")).unwrap();
11557        let built = listener_inode(&seed);
11558        refresh_package_seed(&source, &seeds, &TrustedTrees::default()).unwrap();
11559        assert_ne!(
11560            listener_inode(&seed),
11561            built,
11562            "an unrecorded seed is rebuilt"
11563        );
11564        drop(old_listener);
11565    }
11566
11567    #[cfg(unix)]
11568    #[test]
11569    fn a_package_seed_is_rebuilt_for_a_new_entry_and_drops_every_other() {
11570        let root = tempfile::tempdir().unwrap();
11571        let seeds = root.path().join("runners").join(PACKAGE_SEED_DIR);
11572        let trusted = TrustedTrees::default();
11573        let old = package_entry(root.path(), "2.335.0", "{\"digest\":\"old\"}");
11574        refresh_package_seed(&old, &seeds, &trusted).unwrap();
11575        fs::create_dir_all(seeds.join(".staging-interrupted")).unwrap();
11576        fs::write(seeds.join("stray"), b"").unwrap();
11577
11578        let new = package_entry(root.path(), "2.336.0", "{\"digest\":\"new\"}");
11579        let seed = refresh_package_seed(&new, &seeds, &trusted).unwrap();
11580
11581        let mut left: Vec<_> = fs::read_dir(&seeds)
11582            .unwrap()
11583            .map(|entry| entry.unwrap().file_name().into_string().unwrap())
11584            .collect();
11585        left.sort();
11586        assert_eq!(left, ["2.336.0"]);
11587
11588        // A reinstalled entry of the same version has a different manifest.
11589        fs::write(
11590            new.join(crate::package::MANIFEST_FILE),
11591            "{\"digest\":\"again\"}",
11592        )
11593        .unwrap();
11594        refresh_package_seed(&new, &seeds, &trusted).unwrap();
11595        assert_eq!(
11596            fs::read_to_string(seed.join(crate::package::MANIFEST_FILE)).unwrap(),
11597            "{\"digest\":\"again\"}",
11598            "a stale seed is rebuilt from the changed entry"
11599        );
11600    }
11601
11602    #[cfg(unix)]
11603    #[test]
11604    fn a_package_on_the_runtime_volume_is_its_own_seed() {
11605        let root = tempfile::tempdir().unwrap();
11606        let source = package_entry(root.path(), "2.336.0", "{}");
11607        let runtime = root.path().join("runners").join("attempt");
11608        fs::create_dir_all(&runtime).unwrap();
11609
11610        let trusted = TrustedTrees::default();
11611        assert_eq!(package_seed(&source, &runtime, &trusted).unwrap(), source);
11612        copy_package_tree(&source, &runtime, &trusted).unwrap();
11613        assert!(runtime.join("bin").join("Runner.Listener").exists());
11614        assert!(!root.path().join("runners").join(PACKAGE_SEED_DIR).exists());
11615    }
11616
11617    #[test]
11618    fn copy_package_tree_refuses_top_level_work_folder() {
11619        let root = tempfile::tempdir().unwrap();
11620        let source = root.path().join("source");
11621        let dest = root.path().join("dest");
11622
11623        fs::create_dir_all(&source).unwrap();
11624        fs::write(source.join("file1.txt"), b"hello").unwrap();
11625
11626        // Top-level `_work` should be refused
11627        let top_work = source.join(DEFAULT_WORK_FOLDER);
11628        fs::create_dir_all(&top_work).unwrap();
11629
11630        let err = copy_package_tree(&source, &dest, &TrustedTrees::default()).unwrap_err();
11631        assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
11632    }
11633}