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};
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::{RoutingLabels, 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::{PackageCache, PackageError, RunnerVersion};
51use crate::reconcile::{
52    AllocationGuard, CleanBackoff, EventSink, LaunchFailure, LaunchRequest, LifecycleEvent,
53    OutcomeKind, ReplacementIntent, RunnerLauncher, failure_reason_kind,
54};
55
56mod windows_hyperv;
57pub use windows_hyperv::{WindowsHyperVContainers, WindowsHyperVHostState};
58
59const IDENTITY_FILE: &str = ".runner-process.json";
60const FALLBACK_IDENTITY_FILE: &str = ".runner-process.recovery.json";
61const UNRESOLVED_PROCESS_FILE: &str = ".runner-process.unresolved";
62const RUNNER_ID_FILE: &str = ".github-runner-id";
63const TERMINATE_INTENT_FILE: &str = ".terminate-registration-timeout";
64const MAX_POST_SPAWN_STOP_ATTEMPTS: usize = 3;
65
66/// Slot-root names a cleaned persistent attempt must not have left behind.
67///
68/// This is not the rule — the rule is that *nothing* but a real `_work`
69/// survives, and [`verify_slot_scrubbed`] enforces that by counting. This list
70/// is the second, independent question asked of the same directory: each name
71/// is stat-ed directly, so a scrub that skipped one is caught even if the
72/// enumeration that was supposed to find it under-reported. Every entry is one
73/// of the things `04-security-recovery.md` requires to be proven absent before a
74/// slot is released; the encoded JIT handoff is the one exception, matched by
75/// its published prefix in [`verify_slot_scrubbed`] because the rest of its name
76/// is a UUID. Being compile-time constants, these are also the only entry names
77/// a refusal message is allowed to print.
78const SENSITIVE_SLOT_ENTRIES: &[&str] = &[
79    // Runner binaries and the launchers beside them.
80    "bin",
81    "externals",
82    "run.sh",
83    "run.cmd",
84    "config.sh",
85    "config.cmd",
86    // The registration identity GitHub's runner writes for itself, and the
87    // per-run environment it reads back.
88    ".runner",
89    ".credentials",
90    ".credentials_rsaparams",
91    ".env",
92    ".path",
93    "_diag",
94    // This agent's own process-identity and lifecycle sidecars.
95    IDENTITY_FILE,
96    FALLBACK_IDENTITY_FILE,
97    UNRESOLVED_PROCESS_FILE,
98    RUNNER_ID_FILE,
99    TERMINATE_INTENT_FILE,
100];
101#[cfg(test)]
102const TEST_LISTENER_READY: &str = ".test-listener-ready";
103
104/// GitHub Runner v2.336.0 accepts JIT configuration for `run` through its
105/// secret `ACTIONS_RUNNER_INPUT_JITCONFIG` input. The platform spawn boundary
106/// supplies that input from the restrictive handoff; the listener command line
107/// must contain only the supported `run` command.
108///
109/// The environment is the daemon's plus [`runner_env::platform_defaults`], then
110/// the dependency caches (`caches`, from [`DependencyCaches::for_launch`]), then
111/// the host's `runner.env`, which wins over either of the others.
112/// The per-attempt temporary directory goes last and `runner.env` cannot name
113/// it. On macOS a runner that still starts throttled is reported
114/// ([`SpawnSpec::expect_normal_scheduling`]).
115fn runner_listener_spec(
116    program: PathBuf,
117    runtime: &Path,
118    caches: Vec<(&'static str, std::ffi::OsString)>,
119    host_env: &RunnerEnv,
120) -> SpawnSpec {
121    let tmp = runtime.join("tmp");
122    let _ = std::fs::create_dir_all(&tmp);
123    let defaults = runner_env::platform_defaults(
124        RunnerPlatform::current(),
125        runtime,
126        &runner_env::Inherited::current(),
127        Path::exists,
128    );
129    // The Windows profile the defaults point at. Created even when
130    // `runner.env` overrides part of it: it is inside the attempt, so it costs
131    // nothing and goes with the attempt.
132    for (name, dir) in &defaults {
133        if runner_env::DIRECTORY_VARIABLES.contains(name) {
134            let _ = std::fs::create_dir_all(dir);
135        }
136    }
137    let mut spec = SpawnSpec::new(program)
138        .arg("run")
139        .working_dir(runtime)
140        .expect_normal_scheduling();
141    for (name, value) in runner_env::runner_environment(defaults, caches, host_env) {
142        spec = spec.env(name, value);
143    }
144    spec.env("TMPDIR", &tmp).env("TEMP", &tmp).env("TMP", &tmp)
145}
146
147/// Retry bounds for failures that can resolve without operator action.
148#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149pub struct RetryPolicy {
150    pub max_attempts: u32,
151    pub initial: Duration,
152    pub maximum: Duration,
153}
154
155impl RetryPolicy {
156    #[must_use]
157    pub const fn bounded(max_attempts: u32, initial: Duration, maximum: Duration) -> Self {
158        Self {
159            max_attempts,
160            initial,
161            maximum,
162        }
163    }
164
165    pub(crate) fn delay(self, failure_index: u32) -> Duration {
166        let shift = failure_index.saturating_sub(1).min(31);
167        self.initial
168            .saturating_mul(1_u32 << shift)
169            .min(self.maximum)
170    }
171}
172
173/// Non-secret lifecycle evidence.  Payloads are identifiers and closed enums;
174/// neither the encoded configuration nor child output can enter this type.
175#[derive(Debug, Clone, PartialEq, Eq)]
176pub enum AttemptEvent {
177    State {
178        attempt: AttemptId,
179        state: AttemptState,
180    },
181    Retry {
182        attempt: AttemptId,
183        operation: &'static str,
184        delay: Duration,
185    },
186    /// How long each step of a launch took, whether or not it started a
187    /// runner, so a slow or failed start can be attributed to the wait for the
188    /// allocation lock, the package copy, the JIT request, or the process start.
189    Launched {
190        attempt: AttemptId,
191        started: bool,
192        timings: LaunchTimings,
193    },
194    Adopted {
195        attempt: AttemptId,
196    },
197    RemoteIdentityRecovered {
198        attempt: AttemptId,
199        runner_id: u64,
200    },
201    TerminateIntent {
202        attempt: AttemptId,
203    },
204    Terminated {
205        attempt: AttemptId,
206    },
207    /// The attempt's GitHub registration was removed by this agent. Carries the
208    /// runner id because that is the identifier an operator sees in the
209    /// target's runner settings, and the attempt id is not shown there.
210    Deregistered {
211        attempt: AttemptId,
212        runner_id: u64,
213    },
214    Concluded {
215        attempt: AttemptId,
216        outcome: OutcomeKind,
217    },
218    Cleaned {
219        attempt: AttemptId,
220        outcome: OutcomeKind,
221    },
222    QuarantinedOwnedResource {
223        attempt: AttemptId,
224    },
225}
226
227pub trait AttemptEventSink: fmt::Debug + Send + Sync {
228    fn emit(&self, event: AttemptEvent);
229}
230
231#[derive(Debug, Default)]
232pub struct AttemptEventLog(Mutex<Vec<AttemptEvent>>);
233
234impl AttemptEventLog {
235    #[must_use]
236    pub fn events(&self) -> Vec<AttemptEvent> {
237        self.0
238            .lock()
239            .map(|events| events.clone())
240            .unwrap_or_default()
241    }
242}
243
244impl AttemptEventSink for AttemptEventLog {
245    fn emit(&self, event: AttemptEvent) {
246        if let Ok(mut events) = self.0.lock() {
247            events.push(event);
248        }
249    }
250}
251
252#[derive(Debug, Clone, Copy, Default)]
253pub struct NoAttemptEvents;
254
255impl AttemptEventSink for NoAttemptEvents {
256    fn emit(&self, _event: AttemptEvent) {}
257}
258
259/// Where one launch spent its time. A step the launch never reached is zero.
260#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
261pub struct LaunchTimings {
262    /// Waiting for the host allocation lock, which another launch may hold.
263    pub lock_wait: Duration,
264    pub materialize: Duration,
265    pub register: Duration,
266    pub spawn: Duration,
267}
268
269impl LaunchTimings {
270    #[must_use]
271    pub fn total(&self) -> Duration {
272        self.lock_wait + self.materialize + self.register + self.spawn
273    }
274}
275
276#[derive(Debug, Clone, Copy)]
277enum LaunchStep {
278    Materialize,
279    Register,
280    Spawn,
281}
282
283/// Times a launch's steps; beginning one ends the one before, so an early
284/// return still leaves the step it failed in measured once [`Self::finish`]
285/// runs.
286#[derive(Debug)]
287struct LaunchStopwatch {
288    timings: LaunchTimings,
289    running: Option<(LaunchStep, std::time::Instant)>,
290}
291
292impl LaunchStopwatch {
293    fn new(lock_wait: Duration) -> Self {
294        Self {
295            timings: LaunchTimings {
296                lock_wait,
297                ..LaunchTimings::default()
298            },
299            running: None,
300        }
301    }
302
303    fn begin(&mut self, step: LaunchStep) {
304        self.finish();
305        self.running = Some((step, std::time::Instant::now()));
306    }
307
308    fn finish(&mut self) {
309        if let Some((step, since)) = self.running.take() {
310            let slot = match step {
311                LaunchStep::Materialize => &mut self.timings.materialize,
312                LaunchStep::Register => &mut self.timings.register,
313                LaunchStep::Spawn => &mut self.timings.spawn,
314            };
315            *slot = since.elapsed();
316        }
317    }
318}
319
320/// A launch slower than this is logged as a warning, with its steps.
321///
322/// A healthy launch is a few seconds; a queued job waits for all of it, and
323/// the daemon's default filter drops the `info` line that times every launch.
324const SLOW_LAUNCH: Duration = Duration::from_secs(60);
325
326/// The daemon's sink for retries and launch timings; the other attempt events
327/// already reach the log through the reconcile sink.
328///
329/// Every field name is on `d1`'s allow-list, or the redacting layer would log
330/// `[redacted]` in its place; `tests::every_field_name_the_attempt_sink_emits_is_allowed`
331/// keeps that true.
332#[derive(Debug, Clone, Copy, Default)]
333pub struct TracingAttemptEvents;
334
335impl AttemptEventSink for TracingAttemptEvents {
336    fn emit(&self, event: AttemptEvent) {
337        match event {
338            AttemptEvent::Retry {
339                attempt,
340                operation,
341                delay,
342            } => tracing::warn!(
343                event = "attempt_retry",
344                attempt_id = %attempt,
345                reason = operation,
346                retry_in_ms = millis(delay),
347            ),
348            AttemptEvent::Launched {
349                attempt,
350                started,
351                timings,
352            } => {
353                macro_rules! launched {
354                    ($level:ident, $name:literal) => {
355                        tracing::$level!(
356                            event = $name,
357                            attempt_id = %attempt,
358                            duration_ms = millis(timings.total()),
359                            lock_wait_ms = millis(timings.lock_wait),
360                            materialize_ms = millis(timings.materialize),
361                            register_ms = millis(timings.register),
362                            spawn_ms = millis(timings.spawn),
363                        )
364                    };
365                }
366                if !started {
367                    launched!(warn, "attempt_launch_failed");
368                } else if timings.total() >= SLOW_LAUNCH {
369                    launched!(warn, "attempt_launch_slow");
370                } else {
371                    launched!(info, "attempt_launched");
372                }
373            }
374            _ => {}
375        }
376    }
377}
378
379fn millis(duration: Duration) -> u64 {
380    u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
381}
382
383/// Whether the demand that justified a retry still exists.
384#[async_trait]
385pub trait DemandPersistence: fmt::Debug + Send + Sync {
386    async fn persists(&self, policy: PolicyId) -> bool;
387}
388
389#[derive(Debug, Clone, Copy, Default)]
390pub struct PersistentDemand;
391
392#[async_trait]
393impl DemandPersistence for PersistentDemand {
394    async fn persists(&self, _policy: PolicyId) -> bool {
395        true
396    }
397}
398
399#[async_trait]
400pub trait RetryDelay: fmt::Debug + Send + Sync {
401    async fn wait(&self, duration: Duration);
402}
403
404#[derive(Debug, Clone, Copy, Default)]
405pub struct TokioRetryDelay;
406
407#[async_trait]
408impl RetryDelay for TokioRetryDelay {
409    async fn wait(&self, duration: Duration) {
410        tokio::time::sleep(duration).await;
411    }
412}
413
414#[derive(Debug, Clone, PartialEq, Eq)]
415pub struct JitRequestFailure {
416    pub terminal: bool,
417    pub reason: FailureReason,
418    pub retry_after: Option<Duration>,
419}
420
421/// GitHub's authoritative runner state plus the identity returned by inventory.
422/// The id is carried independently of the local sidecar so recovery can close
423/// the crash boundary immediately after a successful remote registration.
424#[derive(Debug, Clone, Copy, PartialEq, Eq)]
425pub struct LifecycleGithubObservation {
426    pub status: GithubRunnerObservation,
427    pub runner_id: Option<u64>,
428}
429
430impl LifecycleGithubObservation {
431    #[must_use]
432    pub const fn unreachable() -> Self {
433        Self {
434            status: GithubRunnerObservation::Unreachable,
435            runner_id: None,
436        }
437    }
438
439    #[must_use]
440    pub const fn not_registered() -> Self {
441        Self {
442            status: GithubRunnerObservation::NotRegistered,
443            runner_id: None,
444        }
445    }
446
447    #[must_use]
448    pub const fn registered(runner_id: u64, busy: bool) -> Self {
449        Self {
450            status: GithubRunnerObservation::Registered { busy },
451            runner_id: Some(runner_id),
452        }
453    }
454}
455
456/// The two GitHub views the lifecycle needs, combined so one fake can drive
457/// registration and authoritative runner telemetry.
458#[async_trait]
459pub trait LifecycleGithub: fmt::Debug + Send + Sync {
460    async fn register(
461        &self,
462        target: &ScaleTarget,
463        request: &JitRunnerRequest,
464        cancel: &CancelToken,
465    ) -> Result<JitRegistration, JitRequestFailure>;
466
467    async fn observe(
468        &self,
469        target: &ScaleTarget,
470        attempt: AttemptId,
471        cancel: &CancelToken,
472    ) -> LifecycleGithubObservation;
473
474    /// Remove one runner registration this agent created.
475    ///
476    /// Answers whether the registration is gone, and is deliberately not
477    /// fallible in the `Result` sense: no caller may abandon a conclusion
478    /// because GitHub was unreachable. See
479    /// [`LifecycleLauncher::deregister_runner`].
480    async fn deregister(&self, target: &ScaleTarget, runner_id: u64, cancel: &CancelToken) -> bool;
481}
482
483#[async_trait]
484impl<T> LifecycleGithub for T
485where
486    T: JitGateway + InventoryGateway + fmt::Debug + Send + Sync,
487{
488    async fn register(
489        &self,
490        target: &ScaleTarget,
491        request: &JitRunnerRequest,
492        cancel: &CancelToken,
493    ) -> Result<JitRegistration, JitRequestFailure> {
494        self.generate_jit_config(target, request, cancel)
495            .await
496            .map_err(|error| {
497                let reason = if matches!(&error, JitError::Forbidden { .. }) {
498                    FailureReason::Other(
499                        "GitHub refused JIT registration with 403; check the App's runner permission and runner-group access"
500                            .into(),
501                    )
502                } else {
503                    FailureReason::JitRequestFailed
504                };
505                JitRequestFailure {
506                    terminal: error.is_terminal(),
507                    reason,
508                    retry_after: error
509                        .rate_limited()
510                        .map(|limit| limit.delay_from(self.now())),
511                }
512            })
513    }
514
515    async fn observe(
516        &self,
517        target: &ScaleTarget,
518        attempt: AttemptId,
519        cancel: &CancelToken,
520    ) -> LifecycleGithubObservation {
521        let expected_name = runner_name(attempt);
522        match self.list_runners(target, cancel).await {
523            Ok(inventory) => inventory
524                .runners()
525                .iter()
526                .find(|runner| runner.name == expected_name)
527                .map_or(LifecycleGithubObservation::not_registered(), |runner| {
528                    LifecycleGithubObservation::registered(runner.id, runner.busy)
529                }),
530            Err(_) => LifecycleGithubObservation::unreachable(),
531        }
532    }
533
534    async fn deregister(&self, target: &ScaleTarget, runner_id: u64, cancel: &CancelToken) -> bool {
535        self.remove_runner(target, runner_id, cancel).await.is_ok()
536    }
537}
538
539/// Package/cache operations used by one attempt.
540#[async_trait]
541pub trait RuntimePackages: fmt::Debug + Send + Sync {
542    async fn materialize(&self, attempt: &RunnerAttempt) -> Result<RunnerVersion, FailureReason>;
543    fn release(&self, attempt: AttemptId) -> Result<(), FailureReason>;
544    fn prune_obsolete_guarded(
545        &self,
546        authority: PruneAuthority<'_>,
547        current: &RunnerVersion,
548        attempts: &[RunnerAttempt],
549    ) -> Result<(), FailureReason>;
550}
551
552/// Unforgeable evidence that pruning was reached through e1's launch request.
553/// The type is public only because it appears in the public adapter trait; its
554/// private field and constructor prevent callers from substituting a guard
555/// acquired from an unrelated lock.
556pub struct PruneAuthority<'a> {
557    _guard: &'a AllocationGuard,
558}
559
560impl fmt::Debug for PruneAuthority<'_> {
561    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
562        f.write_str("PruneAuthority")
563    }
564}
565
566impl<'a> PruneAuthority<'a> {
567    fn from_launch_request(guard: &'a AllocationGuard) -> Self {
568        Self { _guard: guard }
569    }
570}
571
572/// Production package adapter.  It takes an e2 lease before returning, so a
573/// cache entry can never look unused while its runtime is starting.
574#[derive(Debug)]
575pub struct CachedRuntimePackages {
576    cache: Arc<PackageCache>,
577    trusted: Arc<TrustedTrees>,
578}
579
580impl CachedRuntimePackages {
581    /// Run `work` against a package tree on the blocking pool: a fingerprint
582    /// walks ~9,000 entries and a copy can take minutes.
583    async fn blocking<T: Send + 'static>(
584        &self,
585        root: &Path,
586        work: impl FnOnce(&TrustedTrees, &Path) -> T + Send + 'static,
587    ) -> Result<T, FailureReason> {
588        let trusted = Arc::clone(&self.trusted);
589        let root = root.to_path_buf();
590        tokio::task::spawn_blocking(move || work(&trusted, &root))
591            .await
592            .map_err(|_| FailureReason::ProcessStartFailed)
593    }
594
595    #[must_use]
596    pub fn new(cache: Arc<PackageCache>) -> Self {
597        Self {
598            cache,
599            trusted: Arc::default(),
600        }
601    }
602}
603
604#[async_trait]
605impl RuntimePackages for CachedRuntimePackages {
606    async fn materialize(&self, attempt: &RunnerAttempt) -> Result<RunnerVersion, FailureReason> {
607        let (mut installed, downloaded) = self
608            .cache
609            .ensure_installed_reporting()
610            .await
611            .map_err(package_failure)?;
612        // A cache entry this process did not install, or one that has changed
613        // since, may hold a job's edits; replace it with one downloaded and
614        // verified now. One this call just downloaded is trusted as it lands.
615        // See `TrustedTrees`.
616        let fresh = if downloaded {
617            true
618        } else if self.blocking(installed.root(), TrustedTrees::holds).await? {
619            false
620        } else {
621            installed = self.cache.reinstall().await.map_err(package_failure)?;
622            true
623        };
624        if fresh {
625            self.blocking(installed.root(), TrustedTrees::record)
626                .await?
627                .map_err(|_| FailureReason::ProcessStartFailed)?;
628        }
629        // Off the async thread: the daemon runs a current-thread runtime, and a
630        // copy (or a seed rebuild) can take minutes on a busy disk.
631        let destination = attempt.runtime_path().to_path_buf();
632        let copied = self
633            .blocking(installed.root(), move |trusted, source| {
634                copy_package_tree(source, &destination, trusted)?;
635                // Checked again after the copy: an edit a job made while it ran
636                // moved the entry's print, and the runtime may hold it.
637                if trusted.holds(source) {
638                    Ok(())
639                } else {
640                    Err(std::io::Error::other(
641                        "the runner package changed while copied",
642                    ))
643                }
644            })
645            .await?;
646        if copied.is_err() {
647            // Nothing a failed or tainted copy left may reach a retry's copy,
648            // which lays out over the same runtime.
649            let _ = remove_materialized_package(attempt);
650            return Err(FailureReason::ProcessStartFailed);
651        }
652        if let Err(error) = self.cache.lease(attempt, installed.version()) {
653            // Undoing the copy must not undo the *job* workspace: a persistent
654            // slot's `_work` is retained across attempts, and this rollback
655            // runs before the attempt that would have owned it ever started.
656            let _ = remove_materialized_package(attempt);
657            return Err(package_failure(error));
658        }
659        Ok(installed.version().clone())
660    }
661
662    fn release(&self, attempt: AttemptId) -> Result<(), FailureReason> {
663        self.cache.release(attempt).map_err(package_failure)
664    }
665
666    fn prune_obsolete_guarded(
667        &self,
668        _authority: PruneAuthority<'_>,
669        current: &RunnerVersion,
670        attempts: &[RunnerAttempt],
671    ) -> Result<(), FailureReason> {
672        for installed in self.cache.installed().map_err(package_failure)? {
673            if installed.version() != current {
674                match self.cache.prune(installed.version(), attempts) {
675                    Ok(()) | Err(PackageError::VersionInUse { .. }) => {}
676                    Err(error) => return Err(package_failure(error)),
677                }
678            }
679        }
680        Ok(())
681    }
682}
683
684fn package_failure(error: PackageError) -> FailureReason {
685    error.failure_reason().unwrap_or(FailureReason::Other(
686        "runner package cache operation failed".into(),
687    ))
688}
689
690fn package_failure_is_terminal(reason: &FailureReason) -> bool {
691    matches!(
692        reason,
693        FailureReason::RunnerPackageUnverified | FailureReason::RunnerVersionRejected
694    )
695}
696
697/// How many hex characters of the attempt id name its workspace.
698///
699/// # Why this is not the whole identifier, and why the policy is not in the path
700///
701/// Windows refuses a path over `MAX_PATH`, and the runner writes deep inside
702/// this directory: `_work/<repo>/<repo>/.git/objects/pack/pack-<40 hex>.keep`
703/// is 100 characters on its own before the repository is named twice. The
704/// layout used to add two full identifiers -- the policy's and the attempt's,
705/// 74 characters between them -- and that was enough to put a real checkout
706/// over the line. Measured, not guessed: this repository's own CI failed here
707/// three times in a row at 264 characters against a limit of 260, with
708/// `fatal: cannot write keep file ...: Filename too long`. A repository whose
709/// name is ten characters longer would have missed by fourteen.
710///
711/// The policy identifier is simply redundant -- an attempt identifier is
712/// unique on its own, and nothing reads the directory tree to find a policy's
713/// attempts, because [`crate::lifecycle::LifecycleLauncher`] asks the journal.
714/// Twelve hex characters of the attempt is 48 bits, which for the handful of
715/// directories one host holds at once is not a collision anybody will see, and
716/// the journal keeps the full identifier either way.
717///
718/// Together that is 61 characters returned to the repository name.
719const WORKSPACE_NAME_LEN: usize = 12;
720
721/// The directory name for one attempt's workspace.
722fn workspace_name(id: AttemptId) -> String {
723    let full = id.to_string();
724    full.chars()
725        .filter(|c| *c != '-')
726        .take(WORKSPACE_NAME_LEN)
727        .collect()
728}
729
730/// Where one attempt's files go, and which cleanup algorithm they are owed.
731///
732/// The pair travels together because journalling them apart is exactly the bug
733/// `AttemptWorkspace` exists to prevent: a `runtime_path` under a persistent
734/// root recorded as ephemeral would be removed whole, taking the retained job
735/// workspace with it.
736#[derive(Debug, Clone)]
737struct Placement {
738    runtime: PathBuf,
739    workspace: AttemptWorkspace,
740}
741
742/// A runner-root refusal, rendered for the operator who has to fix it.
743///
744/// `RunnerRootError`'s `Display` already names the path, the relation and the
745/// remediation command, and none of its variants can carry a credential — they
746/// are paths, and `03-migration-rollout.md` requires the remediation command to
747/// reach the operator verbatim.
748fn root_failure(error: RunnerRootError) -> LifecycleError {
749    // Logged here because here is the last place this is known at all. A launch
750    // refused by the runner root fails *before* `record_allocation`, so no
751    // attempt row is ever written: `b2` has nothing to carry the failure on and
752    // `g2` has nothing to show it from. The lifecycle event keeps only
753    // `reason=other`, by `failure_reason_kind`'s rule that no free text may
754    // reach an event -- which left the whole refusal reading
755    // `runner_start_failed reason=other`, once per poll, naming nothing.
756    //
757    // What travels is the *kind* and not the sentence, and that is forced rather
758    // than chosen: `crate::logging` redacts every field it does not allow-list
759    // and then scrubs anything path-shaped out of the ones it does, so a
760    // rendered `RunnerRootError` -- which is mostly paths -- reaches the log as
761    // `[redacted]`. `error_kind` is allow-listed and `RunnerRootError::kind` is
762    // a closed vocabulary that survives the scrub, so this names which of a
763    // dozen causes the operator has. The paths and the remediation reach them
764    // through the command line, which is not redacted.
765    tracing::warn!(
766        error_kind = error.kind(),
767        "the runner root refused this launch, so no attempt was created; the host will \
768         retry every poll until the cause is resolved. Re-running `host set-runtime-root` \
769         with the same path re-runs this check and prints the directory and the \
770         remediation in full"
771    );
772    LifecycleError::Failed(FailureReason::Other(error.to_string()))
773}
774
775/// The lowest positive slot inside `ceiling` that no uncleaned attempt holds.
776///
777/// `leases` is the journal's answer to "which slots are leased"
778/// (`Store::slot_leases_for_policy`), which deliberately includes a terminal
779/// attempt whose cleanup has not finished: that attempt still owns its
780/// directory, so its slot is not free even though it no longer counts against
781/// host capacity. `None` means the ceiling is reached, which is a refusal and
782/// not a reason to allocate `s(ceiling + 1)`.
783fn lowest_free_slot(leases: &[RunnerAttempt], ceiling: NonZeroU16) -> Option<NonZeroU16> {
784    let held: BTreeSet<u16> = leases
785        .iter()
786        .filter_map(|attempt| attempt.workspace().slot_number())
787        .collect();
788    (1..=ceiling.get())
789        .find(|slot| !held.contains(slot))
790        .and_then(NonZeroU16::new)
791}
792
793/// Create `<root>/sN`, or prove that what is already there is a real directory.
794///
795/// A symlink, junction or reparse point standing where the slot should be is
796/// refused rather than followed: it is the one thing that could put an attempt's
797/// files outside the root the operator configured, and
798/// `04-security-recovery.md` requires that case to fail closed rather than to
799/// be repaired here.
800fn create_or_validate_slot(slot: &Path) -> Result<(), LifecycleError> {
801    match fs::symlink_metadata(slot) {
802        // [`is_link_like`] and not `is_symlink`, so that this is the same
803        // question cleanup asks in [`slot_is_present`]: a reparse tag the
804        // standard library has no name for is refused here rather than
805        // allocated into and then quarantined forever by a cleanup that will
806        // not scrub it.
807        Ok(metadata) if is_link_like(&metadata) => Err(slot_refusal(
808            slot,
809            "is a symbolic link, junction or other reparse point, which could place runner \
810             files outside the configured root",
811        )),
812        Ok(metadata) if !metadata.is_dir() => Err(slot_refusal(slot, "is not a directory")),
813        Ok(_) => Ok(()),
814        Err(error) if error.kind() == std::io::ErrorKind::NotFound => fs::create_dir(slot)
815            .map_err(|source| slot_refusal(slot, format!("could not be created: {source}"))),
816        Err(source) => Err(slot_refusal(
817            slot,
818            format!("could not be inspected: {source}"),
819        )),
820    }
821}
822
823/// Accept a slot for reuse only when it is empty or holds one real `_work`.
824///
825/// `02-target-architecture.md`: "Before materialization, a reusable slot must
826/// contain only a valid real `_work` directory or be empty." Everything else —
827/// a leftover `bin/`, a link-shaped `_work`, a stray file — is refused here
828/// rather than cleaned, because deciding whether those bytes are safe is
829/// cleanup's and recovery's job (`c3`), and quietly reusing them would hand one
830/// repository's retained state to the next attempt without anybody choosing to.
831///
832/// The inspection is one level deep and uses `symlink_metadata`, so nothing is
833/// followed while it is being judged.
834fn accept_reusable_slot(slot: &Path) -> Result<(), LifecycleError> {
835    let unreadable =
836        |source: std::io::Error| slot_refusal(slot, format!("could not be read: {source}"));
837    let entries = fs::read_dir(slot).map_err(unreadable)?;
838    let mut refused: Vec<String> = Vec::new();
839    for entry in entries {
840        let entry = entry.map_err(unreadable)?;
841        let name = entry.file_name();
842        let metadata = fs::symlink_metadata(entry.path()).map_err(|source| {
843            slot_refusal(
844                slot,
845                format!("entry {name:?} could not be inspected: {source}"),
846            )
847        })?;
848        // The same predicate cleanup retains by, so a `_work` this accepts is
849        // one [`scrub_slot_entries`] will keep rather than refuse: a link, a
850        // junction or any other reparse point is not a job workspace to either
851        // of them.
852        if is_retainable_work_folder(&name, &metadata) {
853            continue;
854        }
855        refused.push(name.to_string_lossy().into_owned());
856    }
857    if refused.is_empty() {
858        return Ok(());
859    }
860    refused.sort();
861    Err(slot_refusal(
862        slot,
863        format!(
864            "holds {} that this attempt may not reuse: [{}]. A reusable slot is empty or holds \
865             one real `{DEFAULT_WORK_FOLDER}` directory and nothing else; remove or move the \
866             entries listed, or let cleanup and recovery resolve them",
867            if refused.len() == 1 {
868                "an entry"
869            } else {
870                "entries"
871            },
872            refused.join(", ")
873        ),
874    ))
875}
876
877fn slot_refusal(slot: &Path, detail: impl fmt::Display) -> LifecycleError {
878    LifecycleError::Failed(FailureReason::Other(format!(
879        "the persistent slot {} {detail}",
880        slot.display()
881    )))
882}
883
884/// Whether a directory entry names the retained job workspace.
885///
886/// The comparison folds case on Windows because the filesystem does: there
887/// `_Work` and `_work` are one directory, so a case-sensitive test would let
888/// [`scrub_slot_entries`] delete the very directory it exists to keep, let
889/// [`accept_reusable_slot`] refuse a slot that holds nothing but a valid job
890/// workspace, and let a package's top-level `_Work` merge itself into the
891/// previous attempt's `_work`. Elsewhere the two names really are two
892/// directories and only the exact one is the job workspace.
893fn is_work_folder(name: &OsStr) -> bool {
894    if cfg!(windows) {
895        name.eq_ignore_ascii_case(DEFAULT_WORK_FOLDER)
896    } else {
897        name == OsStr::new(DEFAULT_WORK_FOLDER)
898    }
899}
900
901/// Whether the operating system would follow this entry somewhere else.
902///
903/// `FileType::is_symlink` is the whole answer on Unix. On Windows it is not:
904/// the standard library reports only the symlink and mount-point reparse tags,
905/// and the substitution this has to refuse is *any* reparse point standing
906/// where a real directory should be. So the attribute bit is the test there,
907/// and a tag the standard library has no name for fails closed with the two it
908/// does.
909fn is_link_like(metadata: &fs::Metadata) -> bool {
910    if metadata.file_type().is_symlink() {
911        return true;
912    }
913    #[cfg(windows)]
914    {
915        use std::os::windows::fs::MetadataExt;
916
917        const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
918        metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
919    }
920    #[cfg(not(windows))]
921    false
922}
923
924/// Whether an entry is the retained job workspace and is safe to retain.
925///
926/// The two halves are one question. A `_work` that is a file, a symlink, a
927/// junction or any other reparse point is not a job workspace, and it is also
928/// the exact substitution a hostile workflow makes to send cleanup somewhere
929/// else (`04-security-recovery.md`, "A workflow replaces `_work` with a
930/// junction or symlink to escape cleanup").
931fn is_retainable_work_folder(name: &OsStr, metadata: &fs::Metadata) -> bool {
932    is_work_folder(name) && metadata.is_dir() && !is_link_like(metadata)
933}
934
935/// Undo one package materialization, dispatching on the journalled workspace.
936///
937/// The ephemeral half is what this always did: the directory is the attempt's
938/// alone, so it goes whole. The persistent half removes the copy and nothing
939/// else, because the slot's `_work` predates this attempt and outlives it
940/// (`02-target-architecture.md`, "Persistent repository").
941fn remove_materialized_package(attempt: &RunnerAttempt) -> std::io::Result<()> {
942    match attempt.workspace() {
943        AttemptWorkspace::Ephemeral => remove_runtime_tree(attempt.runtime_path()),
944        AttemptWorkspace::PersistentSlot { .. } => scrub_slot_entries(attempt.runtime_path())
945            .map_err(|quarantine| std::io::Error::other(quarantine.to_string())),
946    }
947}
948
949/// Remove a disposable runner tree without opening workflow-created special files.
950///
951/// On Unix, `remove_dir_all` 1.0 can open a FIFO while walking a directory and
952/// wait forever for its peer. The .NET runner routinely leaves diagnostic FIFOs
953/// in its private `tmp`, so use the standard library's fd-relative Unix remover,
954/// which unlinks non-directories without opening them. Keep the external remover
955/// on Windows for its existing read-only and long-path handling; when it is
956/// refused, [`unlink_reparse_points`] takes every link out of its way and it
957/// runs once more.
958fn remove_runtime_tree(path: &Path) -> std::io::Result<()> {
959    #[cfg(windows)]
960    {
961        match remove_dir_all::remove_dir_all(path) {
962            Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
963                unlink_reparse_points(path);
964                remove_dir_all::remove_dir_all(path)
965            }
966            other => other,
967        }
968    }
969    #[cfg(not(windows))]
970    {
971        fs::remove_dir_all(path)
972    }
973}
974
975/// Remove every symlink, junction and other reparse point under `dir` as a
976/// link, without ever descending into one.
977///
978/// WinINet creates `…\INetCache\Content.IE5` as a junction whose own DACL denies
979/// `Everyone` the right to list it, and a job that points `USERPROFILE` or
980/// `LOCALAPPDATA` into its workspace gets one there. Both `remove_dir_all` 1.0
981/// and `std::fs::remove_dir_all` then fail on the whole tree with
982/// `ERROR_ACCESS_DENIED`, so the attempt could never be cleaned. Removing the
983/// link itself is a different operation (`RemoveDirectoryW` on the reparse
984/// point) that the deny entry does not cover, and once the links are gone the
985/// tree is ordinary.
986///
987/// Only walked after a refusal, so an ordinary tree is traversed once. The
988/// metadata comes from the directory listing itself, which on Windows
989/// describes a reparse point without opening it.
990///
991/// Best effort: a failure here is not reported, because
992/// [`remove_runtime_tree`]'s second `remove_dir_all` runs next and its result
993/// is the authority on whether the tree went.
994#[cfg(windows)]
995fn unlink_reparse_points(dir: &Path) {
996    let Ok(entries) = fs::read_dir(dir) else {
997        return;
998    };
999    for entry in entries.flatten() {
1000        let path = entry.path();
1001        let Ok(metadata) = entry.metadata() else {
1002            continue;
1003        };
1004        if is_link_like(&metadata) {
1005            let _ = fs::remove_dir(&path).or_else(|_| fs::remove_file(&path));
1006        } else if metadata.is_dir() {
1007            unlink_reparse_points(&path);
1008        }
1009    }
1010}
1011
1012// ---------------------------------------------------------------------------
1013// Persistent cleanup (`04-security-recovery.md`, "Safe path handling")
1014// ---------------------------------------------------------------------------
1015
1016/// Why a persistent slot could not be proven safe to scrub.
1017///
1018/// A closed set rather than a formatted string, for two reasons that point the
1019/// same way. [`LifecycleEvent::AttemptCleanFailed`] takes a `&'static str` for
1020/// exactly the reason [`crate::reconcile::failure_reason_kind`] documents —
1021/// free text is the one shape that can carry a credential past a field
1022/// allow-list. And the entries under a slot root are *workflow-controlled*: a
1023/// job that writes a file named after a secret would publish it through any
1024/// message that echoed a directory listing, which is why nothing here ever
1025/// renders an entry name that did not come from this module's own constants.
1026#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1027enum SlotRefusal {
1028    /// The journalled runtime path is not `<root>/sN` for the journalled slot.
1029    NotTheJournalledSlot,
1030    /// A policy that still exists names a different root than the journal does.
1031    PolicyRootDisagrees,
1032    /// The slot is not strictly inside its root once components resolve.
1033    Containment,
1034    /// The slot itself is a file, a link, or could not be inspected.
1035    SlotNotADirectory,
1036    /// The slot's direct entries could not be listed.
1037    Enumeration,
1038    /// `_work` is a file, a symlink, a junction or another reparse point.
1039    WorkNotADirectory,
1040    /// An entry that had to go could not be removed.
1041    Deletion,
1042    /// Something other than the job workspace survived removal.
1043    Residue,
1044}
1045
1046impl SlotRefusal {
1047    /// The event field: a fixed vocabulary, never operator or workflow text.
1048    const fn class(self) -> &'static str {
1049        match self {
1050            Self::NotTheJournalledSlot => "slot_path_is_not_the_journalled_slot",
1051            Self::PolicyRootDisagrees => "slot_root_disagrees_with_policy",
1052            Self::Containment => "slot_escapes_its_root",
1053            Self::SlotNotADirectory => "slot_is_not_a_directory",
1054            Self::Enumeration => "slot_could_not_be_enumerated",
1055            Self::WorkNotADirectory => "retained_work_is_not_a_directory",
1056            Self::Deletion => "slot_entry_could_not_be_removed",
1057            Self::Residue => "slot_still_holds_runner_state",
1058        }
1059    }
1060
1061    /// What the operator has to do, in one sentence and with no path in it.
1062    const fn remediation(self) -> &'static str {
1063        match self {
1064            Self::NotTheJournalledSlot | Self::PolicyRootDisagrees | Self::Containment => {
1065                "the attempt keeps its slot lease and nothing was removed; correct the \
1066                 repository's persistent workspace path, or remove the slot directory by hand \
1067                 once you have confirmed what is in it"
1068            }
1069            Self::SlotNotADirectory | Self::WorkNotADirectory => {
1070                "the attempt keeps its slot lease and nothing was removed; a job replaced the \
1071                 slot or its `_work` with a link, so inspect it before deleting anything and \
1072                 treat the retained workspace as untrusted"
1073            }
1074            Self::Enumeration | Self::Deletion | Self::Residue => {
1075                "the attempt keeps its slot lease and will be cleaned again on the next pass; \
1076                 release whatever is holding the files open, or remove the slot's contents by \
1077                 hand leaving only `_work`"
1078            }
1079        }
1080    }
1081}
1082
1083/// A refusal that leaves one persistent slot quarantined.
1084///
1085/// `detail` is redacted by construction: it may hold paths this product
1086/// configured, `std::io::ErrorKind` values, counts, and names drawn from
1087/// [`SENSITIVE_SLOT_ENTRIES`] — and nothing that came out of a directory
1088/// listing.
1089#[derive(Debug, Clone, PartialEq, Eq)]
1090struct SlotQuarantine {
1091    refusal: SlotRefusal,
1092    detail: String,
1093}
1094
1095impl SlotQuarantine {
1096    fn new(refusal: SlotRefusal, detail: impl Into<String>) -> Self {
1097        Self {
1098            refusal,
1099            detail: detail.into(),
1100        }
1101    }
1102}
1103
1104impl fmt::Display for SlotQuarantine {
1105    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1106        write!(f, "{}; {}", self.detail, self.refusal.remediation())
1107    }
1108}
1109
1110/// Steps 1 to 3: the journalled root, the journalled slot, and containment.
1111///
1112/// Everything comes from the two immutable allocation facts — the exact runtime
1113/// path and the slot number. Not from the filesystem: scanning a root to decide
1114/// which directories are "mine" is what invariant 6 forbids, and it is also
1115/// impossible for an attempt whose policy has since been deleted. `configured`
1116/// is therefore a *cross-check* and not a source. A policy that survives has to
1117/// agree; a policy that does not survive removes a check rather than the
1118/// ability to clean the directory the journal already names.
1119///
1120/// The name test comes before the parent is used, so `<root>/s1` for a journal
1121/// row that says `s2` — and anything at all that is not one `sN` component —
1122/// is refused before a root is derived from it.
1123fn verify_journalled_slot(
1124    runtime: &Path,
1125    slot: NonZeroU16,
1126    configured: Option<&LocalAbsolutePath>,
1127) -> Result<(), SlotQuarantine> {
1128    let mislaid = || {
1129        SlotQuarantine::new(
1130            SlotRefusal::NotTheJournalledSlot,
1131            format!(
1132                "the journalled runtime {} is not the slot s{slot} this attempt was allocated as",
1133                runtime.display()
1134            ),
1135        )
1136    };
1137    let local = |path: &Path| {
1138        path.to_str()
1139            .and_then(|raw| LocalAbsolutePath::new(raw).ok())
1140            .ok_or_else(mislaid)
1141    };
1142
1143    let runtime_path = local(runtime)?;
1144    let root = local(runtime.parent().ok_or_else(mislaid)?)?;
1145    // Containment's lexical half, by construction: `derive_child` accepts one
1146    // component, so the equality below can only hold when the journalled path
1147    // really is this root's `sN` and nothing else.
1148    // The directory name comes from the domain that allocation named it with,
1149    // never from a second `s{n}` spelled out here: a convention with two
1150    // spellings would let cleanup refuse every slot the allocator created.
1151    let name = AttemptWorkspace::persistent_slot(slot)
1152        .slot_directory_name()
1153        .expect("a persistent workspace names its slot directory");
1154    let derived = runner_root::derive_child(&root, &name).map_err(|_| mislaid())?;
1155    if derived != runtime_path {
1156        return Err(mislaid());
1157    }
1158    if let Some(configured) = configured
1159        && configured != &root
1160    {
1161        return Err(SlotQuarantine::new(
1162            SlotRefusal::PolicyRootDisagrees,
1163            format!(
1164                "the journalled slot {} is not under the repository's configured persistent root \
1165                 {}",
1166                runtime.display(),
1167                configured.as_str()
1168            ),
1169        ));
1170    }
1171    // And containment's canonical half, which is what a junction planted inside
1172    // the root between allocation and cleanup has to get past.
1173    runner_root::verify_containment(&root, &derived).map_err(|source| {
1174        SlotQuarantine::new(
1175            SlotRefusal::Containment,
1176            format!("the journalled slot is not inside the root it was allocated from: {source}"),
1177        )
1178    })
1179}
1180
1181/// Whether the slot is there to be scrubbed at all, before its entries are.
1182///
1183/// `Ok(false)` — the directory is gone — is not a refusal. There is nothing to
1184/// remove and nothing to prove absent, which is the same tolerance the
1185/// disposable arm has always had for a runtime that vanished under it.
1186///
1187/// A slot that is a file, a link, a junction or any other reparse point *is* a
1188/// refusal, and it is checked here rather than inside the enumeration so that
1189/// the reason an operator reads names the shape rather than reporting that a
1190/// directory could not be listed.
1191fn slot_is_present(slot: &Path) -> Result<bool, SlotQuarantine> {
1192    match fs::symlink_metadata(slot) {
1193        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
1194        Err(source) => Err(SlotQuarantine::new(
1195            SlotRefusal::SlotNotADirectory,
1196            format!(
1197                "the slot {} could not be inspected: {:?}",
1198                slot.display(),
1199                source.kind()
1200            ),
1201        )),
1202        Ok(metadata) if !metadata.is_dir() || is_link_like(&metadata) => Err(SlotQuarantine::new(
1203            SlotRefusal::SlotNotADirectory,
1204            format!(
1205                "the slot {} is a link or a file rather than a real directory",
1206                slot.display()
1207            ),
1208        )),
1209        Ok(_) => Ok(true),
1210    }
1211}
1212
1213/// Steps 4 to 6: list the slot's direct entries, keep one real `_work`, remove
1214/// every other one.
1215///
1216/// Every call is a literal filesystem API against a path built from the slot
1217/// root and one entry name — no glob, no shell string, no repository-controlled
1218/// fragment. Nothing is followed: `symlink_metadata` says what each entry *is*,
1219/// and a link-shaped entry is unlinked rather than descended into, so a junction
1220/// planted where `bin` used to be cannot take the deletion outside the slot.
1221///
1222/// A `_work` that is not a real directory is the one entry this refuses to act
1223/// on at all. Removing it could destroy an operator's data if the link were
1224/// theirs; keeping it would hand the next attempt a workspace pointing
1225/// anywhere. `04-security-recovery.md` requires that case to quarantine the
1226/// slot, so the whole scrub stops there with nothing removed after it.
1227fn scrub_slot_entries(slot: &Path) -> Result<(), SlotQuarantine> {
1228    let unreadable = |source: std::io::Error| {
1229        SlotQuarantine::new(
1230            SlotRefusal::Enumeration,
1231            format!(
1232                "the entries of {} could not be listed: {:?}",
1233                slot.display(),
1234                source.kind()
1235            ),
1236        )
1237    };
1238    for entry in fs::read_dir(slot).map_err(unreadable)? {
1239        let name = entry.map_err(unreadable)?.file_name();
1240        let path = slot.join(&name);
1241        // Nothing is assumed from an entry that vanished: `verify_slot_scrubbed`
1242        // asks the filesystem again afterwards and refuses if it is still there.
1243        let Some(metadata) = listed_entry_metadata(&path).map_err(unreadable)? else {
1244            continue;
1245        };
1246        if is_work_folder(&name) {
1247            if is_retainable_work_folder(&name, &metadata) {
1248                continue;
1249            }
1250            return Err(SlotQuarantine::new(
1251                SlotRefusal::WorkNotADirectory,
1252                format!(
1253                    "the retained `{DEFAULT_WORK_FOLDER}` in {} is a link or a file rather than a \
1254                     real directory",
1255                    slot.display()
1256                ),
1257            ));
1258        }
1259        remove_slot_entry(&path, &metadata).map_err(|source| {
1260            SlotQuarantine::new(
1261                SlotRefusal::Deletion,
1262                format!(
1263                    "an entry of {} could not be removed: {:?}",
1264                    slot.display(),
1265                    source.kind()
1266                ),
1267            )
1268        })?;
1269    }
1270    Ok(())
1271}
1272
1273/// What a listed entry *is*, or `None` when it is no longer there.
1274///
1275/// An entry named by a listing and gone by the time it is stat-ed is absent,
1276/// which is a fact both passes over a slot want rather than an enumeration that
1277/// failed. Nothing is followed: `symlink_metadata` reports a link as a link.
1278fn listed_entry_metadata(path: &Path) -> std::io::Result<Option<fs::Metadata>> {
1279    match fs::symlink_metadata(path) {
1280        Ok(metadata) => Ok(Some(metadata)),
1281        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
1282        Err(source) => Err(source),
1283    }
1284}
1285
1286/// Remove one slot entry without following it.
1287///
1288/// An entry that is already gone is removed: the post-condition this serves is
1289/// "not there", and racing with whatever removed it first is not a refusal.
1290fn remove_slot_entry(path: &Path, metadata: &fs::Metadata) -> std::io::Result<()> {
1291    let removed = if is_link_like(metadata) {
1292        // A file symlink unlinks with `remove_file`; a directory symlink or a
1293        // Windows junction needs `remove_dir`. Neither follows the link.
1294        fs::remove_file(path).or_else(|_| fs::remove_dir(path))
1295    } else if metadata.is_dir() {
1296        remove_runtime_tree(path)
1297    } else {
1298        fs::remove_file(path)
1299    };
1300    match removed {
1301        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
1302        other => other,
1303    }
1304}
1305
1306/// Step 7: prove that nothing but the retained job workspace is left.
1307///
1308/// Two passes, deliberately independent, because the interesting failure is an
1309/// enumeration that under-reports. The first asks the directory what remains
1310/// and counts everything that is not a real `_work`. The second ignores the
1311/// listing entirely and stats the names an attempt is known to write — the
1312/// runner's binaries, the registration identity it stores beside them, the
1313/// process-identity sidecars and this agent's lifecycle marks — so a scrub that
1314/// silently skipped one is caught by a question that never consulted the
1315/// listing that skipped it.
1316///
1317/// Only the second pass names anything, and the encoded JIT handoff is reported
1318/// by its published prefix rather than by the UUID that follows it. A slot root
1319/// is workflow-writable, so a job that named a file after a secret would publish
1320/// it through any message that echoed the listing; the first pass therefore
1321/// reports a count.
1322fn verify_slot_scrubbed(slot: &Path) -> Result<(), SlotQuarantine> {
1323    let unreadable = |source: std::io::Error| {
1324        SlotQuarantine::new(
1325            SlotRefusal::Enumeration,
1326            format!(
1327                "the entries of {} could not be listed to verify the scrub: {:?}",
1328                slot.display(),
1329                source.kind()
1330            ),
1331        )
1332    };
1333    let mut residue = 0_usize;
1334    let mut named: Vec<String> = Vec::new();
1335    for entry in fs::read_dir(slot).map_err(unreadable)? {
1336        let name = entry.map_err(unreadable)?.file_name();
1337        // Absent is what this pass is here to establish. The named half below
1338        // stats every sensitive entry again, so an entry that only *looks*
1339        // absent to this listing is still caught.
1340        let Some(metadata) = listed_entry_metadata(&slot.join(&name)).map_err(unreadable)? else {
1341            continue;
1342        };
1343        if is_retainable_work_folder(&name, &metadata) {
1344            continue;
1345        }
1346        residue = residue.saturating_add(1);
1347        if name
1348            .to_string_lossy()
1349            .starts_with(RestrictiveHandoff::NAME_PREFIX)
1350        {
1351            named.push("an encoded JIT handoff".to_owned());
1352        }
1353    }
1354    named.extend(
1355        SENSITIVE_SLOT_ENTRIES
1356            .iter()
1357            .filter(|entry| fs::symlink_metadata(slot.join(entry)).is_ok())
1358            .map(|entry| format!("`{entry}`")),
1359    );
1360    if residue == 0 && named.is_empty() {
1361        return Ok(());
1362    }
1363    named.sort_unstable();
1364    named.dedup();
1365    Err(SlotQuarantine::new(
1366        SlotRefusal::Residue,
1367        residue_detail(slot, residue, &named),
1368    ))
1369}
1370
1371/// Word a [`SlotRefusal::Residue`] refusal from the two facts that produced it.
1372///
1373/// Separate from [`verify_slot_scrubbed`] because the disagreement it has to
1374/// report — a listing that counted nothing and a filesystem that answered
1375/// otherwise — is a race no test can stage, and a message that contradicts
1376/// itself is exactly what an operator reads at three in the morning.
1377///
1378/// `named` is the sanitized half: entries this crate published the names of.
1379/// A name a workflow chose is only ever counted, never echoed.
1380fn residue_detail(slot: &Path, residue: usize, named: &[String]) -> String {
1381    if residue == 0 {
1382        // The whole reason the second pass ignores the listing: the listing
1383        // reported a clean slot and the filesystem disagrees. Saying "0
1384        // entries survived" here would report the under-count as the fact.
1385        format!(
1386            "the listing of {} reported nothing but `{DEFAULT_WORK_FOLDER}`, yet {} survived \
1387                 cleanup",
1388            slot.display(),
1389            named.join(", ")
1390        )
1391    } else {
1392        format!(
1393            "{residue} entr{} other than `{DEFAULT_WORK_FOLDER}` survived cleanup of {}{}",
1394            if residue == 1 { "y" } else { "ies" },
1395            slot.display(),
1396            if named.is_empty() {
1397                String::new()
1398            } else {
1399                format!(", including {}", named.join(", "))
1400            }
1401        )
1402    }
1403}
1404
1405fn replacement_operation(outcome: &AttemptOutcome) -> Option<&'static str> {
1406    match outcome {
1407        AttemptOutcome::Failed {
1408            reason: FailureReason::JitExpired,
1409        } => Some("jit_expired_replacement"),
1410        AttemptOutcome::Failed {
1411            reason: FailureReason::ProcessExitedUnexpectedly,
1412        } => Some("exit_before_acceptance_replacement"),
1413        _ => None,
1414    }
1415}
1416
1417/// Lay the verified runner package out *around* whatever the slot retains.
1418///
1419/// `02-target-architecture.md`: "The verified runner package is copied into the
1420/// slot for the attempt", beside a `_work` that survives every attempt. Two
1421/// properties make that safe, and both are structural rather than documented:
1422///
1423/// * the walk is of the **source** tree, so a retained `_work` in the
1424///   destination is never opened, never descended into, and cannot be followed
1425///   wherever it might point;
1426/// * a top-level source entry named `_work` is refused rather than copied, so a
1427///   package that ever grew one could not merge itself into, or replace, the
1428///   job workspace of the attempt before it. The refusal is top-level only,
1429///   because the retained directory is a direct child of the slot; a `_work`
1430///   nested inside the package's own tree is an ordinary name.
1431///
1432/// On Unix the copy is a copy-on-write clone where the filesystem offers one,
1433/// taken from a [`package_seed`] on the destination's own volume. A plain
1434/// byte copy of the ~9,000-file package across volumes took four minutes on a
1435/// host whose runner root shared a busy disk with a build, and a queued job
1436/// waits for every second of it.
1437fn copy_package_tree(
1438    source: &Path,
1439    destination: &Path,
1440    seeds: &TrustedTrees,
1441) -> std::io::Result<()> {
1442    refuse_work_folder(source)?;
1443
1444    #[cfg(unix)]
1445    {
1446        // A seed that cannot be made is a slower launch, not a failed one.
1447        // The seed sits under the runner root, beside job runtimes, so the
1448        // refusal is re-checked on it; a seed that fails it is bypassed.
1449        let origin = package_seed(source, destination, seeds)
1450            .ok()
1451            .filter(|seed| refuse_work_folder(seed).is_ok())
1452            .unwrap_or_else(|| source.to_path_buf());
1453        clone_tree(&origin, destination)?;
1454        // A seed a job edited during the clone no longer holds; the caller
1455        // re-checks the cache entry itself.
1456        if origin != source && !seeds.holds(&origin) {
1457            return Err(std::io::Error::other(
1458                "the package seed changed while cloned",
1459            ));
1460        }
1461        Ok(())
1462    }
1463    #[cfg(not(unix))]
1464    {
1465        let _ = seeds;
1466        copy_package_entries(source, destination, true)
1467    }
1468}
1469
1470fn refuse_work_folder(source: &Path) -> std::io::Result<()> {
1471    if source.join(DEFAULT_WORK_FOLDER).exists() {
1472        return Err(std::io::Error::new(
1473            std::io::ErrorKind::InvalidData,
1474            "a cached runner package contains a _work folder, which means it was used \
1475             to run a job before it was archived; the cache must only contain clean \
1476             extracts to prevent data leakage",
1477        ));
1478    }
1479    Ok(())
1480}
1481
1482/// Where runtime copies of the package are cloned from, beside the runtimes.
1483#[cfg(unix)]
1484const PACKAGE_SEED_DIR: &str = ".runner-package";
1485
1486/// A copy of the cache entry on the destination's volume, so that every
1487/// runtime after the first is a same-volume clone rather than a cross-volume
1488/// byte copy. Returns `source` itself when it already shares the volume.
1489///
1490/// The seed lives at `<runner root>/.runner-package/<version>` and is replaced
1491/// whenever its manifest stops matching the cache entry's, which covers both a
1492/// new version and a reinstalled one. Superseded versions and interrupted
1493/// stagings are removed on that same pass. Launches hold the host allocation
1494/// lock for the whole materialization, so no other launch is cloning from a
1495/// seed while it is replaced.
1496///
1497/// The seed sits one `..` from every job's runtime and is owned by the account
1498/// jobs run as, so it is only reused while it is provably the tree this
1499/// process built: see [`TrustedTrees`].
1500///
1501/// Only on macOS, where the runner root is all but certainly APFS and a clone
1502/// is free. On a Linux filesystem without reflinks a seed would only move every
1503/// copy's reads onto the runner root's own, possibly busy, disk.
1504#[cfg(unix)]
1505fn package_seed(
1506    source: &Path,
1507    destination: &Path,
1508    seeds: &TrustedTrees,
1509) -> std::io::Result<PathBuf> {
1510    use std::os::unix::fs::MetadataExt as _;
1511
1512    if !cfg!(target_os = "macos") {
1513        return Ok(source.to_path_buf());
1514    }
1515    let root = destination
1516        .parent()
1517        .ok_or_else(|| std::io::Error::other("a runtime directory has no parent"))?;
1518    if fs::metadata(source)?.dev() == fs::metadata(root)?.dev() {
1519        return Ok(source.to_path_buf());
1520    }
1521    refresh_package_seed(source, &root.join(PACKAGE_SEED_DIR), seeds)
1522}
1523
1524/// The package trees this process built or installed, and what each looked
1525/// like right after: the cache entry runtimes are copied from and the seeds
1526/// they are cloned from.
1527///
1528/// Both are owned by the account jobs run as, so a job can edit any file in
1529/// them, and nothing on disk can say what they should hold: the published
1530/// digest covers the archive, not the extracted tree, and a record kept on disk
1531/// could be rewritten by the same account. So a tree is trusted only while its
1532/// [`tree_fingerprint`] matches the one recorded here. Every edit, replacement
1533/// or rename moves the change time (`ctime`) of the entry or of its directory,
1534/// and only the superuser can set a change time. A tree without a record, as
1535/// every tree has after a restart, is rebuilt before it is used.
1536///
1537/// Unix only. On Windows every tree is trusted, as before.
1538#[derive(Debug, Default)]
1539pub(crate) struct TrustedTrees {
1540    #[cfg_attr(not(unix), allow(dead_code))]
1541    prints: Mutex<std::collections::HashMap<PathBuf, [u8; 32]>>,
1542}
1543
1544impl TrustedTrees {
1545    /// Whether `root` is a tree recorded here and still looks as it did.
1546    fn holds(&self, root: &Path) -> bool {
1547        #[cfg(unix)]
1548        {
1549            let recorded = self
1550                .prints
1551                .lock()
1552                .unwrap_or_else(std::sync::PoisonError::into_inner)
1553                .get(root)
1554                .copied();
1555            recorded.is_some_and(|recorded| {
1556                tree_fingerprint(root).is_ok_and(|current| current == recorded)
1557            })
1558        }
1559        #[cfg(not(unix))]
1560        {
1561            let _ = root;
1562            true
1563        }
1564    }
1565
1566    /// Trust `root` as it is now; called right after this process made it.
1567    fn record(&self, root: &Path) -> std::io::Result<()> {
1568        #[cfg(unix)]
1569        {
1570            let print = tree_fingerprint(root)?;
1571            self.prints
1572                .lock()
1573                .unwrap_or_else(std::sync::PoisonError::into_inner)
1574                .insert(root.to_path_buf(), print);
1575        }
1576        #[cfg(not(unix))]
1577        let _ = root;
1578        Ok(())
1579    }
1580}
1581
1582/// A digest of every entry under `root`: its relative path, inode, size, mode,
1583/// owner and change time.
1584#[cfg(unix)]
1585fn tree_fingerprint(root: &Path) -> std::io::Result<[u8; 32]> {
1586    use sha2::{Digest as _, Sha256};
1587    use std::os::unix::fs::MetadataExt as _;
1588
1589    fn walk(root: &Path, path: &Path, hasher: &mut Sha256) -> std::io::Result<()> {
1590        let metadata = fs::symlink_metadata(path)?;
1591        let relative = path.strip_prefix(root).unwrap_or(path);
1592        hasher.update(relative.as_os_str().as_encoded_bytes());
1593        hasher.update([0]);
1594        for field in [
1595            metadata.ino(),
1596            metadata.size(),
1597            u64::from(metadata.mode()),
1598            u64::from(metadata.uid()),
1599            u64::from(metadata.gid()),
1600        ] {
1601            hasher.update(field.to_le_bytes());
1602        }
1603        hasher.update(metadata.ctime().to_le_bytes());
1604        hasher.update(metadata.ctime_nsec().to_le_bytes());
1605        if metadata.is_dir() {
1606            let mut children = fs::read_dir(path)?
1607                .map(|entry| entry.map(|entry| entry.path()))
1608                .collect::<std::io::Result<Vec<_>>>()?;
1609            children.sort();
1610            for child in children {
1611                walk(root, &child, hasher)?;
1612            }
1613        }
1614        Ok(())
1615    }
1616
1617    let mut hasher = Sha256::new();
1618    walk(root, root, &mut hasher)?;
1619    Ok(hasher.finalize().into())
1620}
1621
1622/// The seed of `source` under `seeds`, rebuilt unless its manifest matches the
1623/// cache entry's and its fingerprint the one this process recorded.
1624#[cfg(unix)]
1625fn refresh_package_seed(
1626    source: &Path,
1627    seeds: &Path,
1628    trusted: &TrustedTrees,
1629) -> std::io::Result<PathBuf> {
1630    use crate::package::MANIFEST_FILE;
1631
1632    let version = source
1633        .file_name()
1634        .ok_or_else(|| std::io::Error::other("a cache entry has no name"))?;
1635    let seed = seeds.join(version);
1636    let manifest = fs::read(source.join(MANIFEST_FILE))?;
1637    if fs::read(seed.join(MANIFEST_FILE)).is_ok_and(|held| held == manifest) && trusted.holds(&seed)
1638    {
1639        return Ok(seed);
1640    }
1641
1642    // Superseded versions, a stale seed and interrupted stagings all go.
1643    match remove_runtime_tree(seeds) {
1644        Err(error) if error.kind() != std::io::ErrorKind::NotFound => return Err(error),
1645        _ => {}
1646    }
1647    fs::create_dir_all(seeds)?;
1648    let staging = seeds.join(format!(".staging-{}", uuid::Uuid::new_v4()));
1649    fs::create_dir(&staging)?;
1650    // A half-made staging would hold the disk space the fallback copy needs.
1651    if let Err(error) = clone_tree(source, &staging).and_then(|()| fs::rename(&staging, &seed)) {
1652        let _ = remove_runtime_tree(&staging);
1653        return Err(error);
1654    }
1655    // After the rename, which itself moves the seed directory's change time.
1656    trusted.record(&seed)?;
1657    Ok(seed)
1658}
1659
1660/// `cp -a`, asking for a copy-on-write clone where the platform's `cp` can.
1661///
1662/// macOS `cp -c` and GNU `--reflink=auto` both fall back to a byte copy across
1663/// volumes; a `cp` that does not know the flag at all gets a plain `cp -a`.
1664#[cfg(unix)]
1665fn clone_tree(source: &Path, destination: &Path) -> std::io::Result<()> {
1666    #[cfg(target_os = "macos")]
1667    const CLONE: &[&str] = &["-c"];
1668    #[cfg(target_os = "linux")]
1669    const CLONE: &[&str] = &["--reflink=auto"];
1670    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
1671    const CLONE: &[&str] = &[];
1672
1673    let from = format!("{}/.", source.display());
1674    let cp = |extra: &[&str]| {
1675        std::process::Command::new("cp")
1676            .arg("-a")
1677            .args(extra)
1678            .arg(&from)
1679            .arg(destination)
1680            .status()
1681    };
1682    if cp(CLONE)?.success() || cp(&[])?.success() {
1683        Ok(())
1684    } else {
1685        Err(std::io::Error::other("cp failed"))
1686    }
1687}
1688
1689#[cfg(not(unix))]
1690fn copy_package_entries(source: &Path, destination: &Path, top_level: bool) -> std::io::Result<()> {
1691    fs::create_dir_all(destination)?;
1692    for entry in fs::read_dir(source)? {
1693        let entry = entry?;
1694        if top_level && is_work_folder(&entry.file_name()) {
1695            return Err(std::io::Error::new(
1696                std::io::ErrorKind::InvalidData,
1697                format!(
1698                    "the runner package holds a top-level `{DEFAULT_WORK_FOLDER}`; copying \
1699                     it would overwrite the job workspace a persistent slot retains"
1700                ),
1701            ));
1702        }
1703        let target = destination.join(entry.file_name());
1704        if entry.file_type()?.is_dir() {
1705            copy_package_entries(&entry.path(), &target, false)?;
1706        } else {
1707            fs::copy(entry.path(), target)?;
1708        }
1709    }
1710    Ok(())
1711}
1712
1713/// Process operations are attempt-addressed so a recovered process and a child
1714/// started in this invocation are supervised through one port.
1715#[derive(Debug, Clone, PartialEq, Eq)]
1716pub struct ProcessStartFailure {
1717    pub reason: FailureReason,
1718    /// False once a child existed: the one-shot JIT value may have been
1719    /// consumed, so retrying it could start a duplicate.
1720    pub retryable: bool,
1721    /// Set only when cleanup could not prove the spawned process dead.  The
1722    /// caller must journal `starting` and retain capacity/supervision.
1723    pub live_pid: Option<u32>,
1724}
1725
1726impl ProcessStartFailure {
1727    pub(crate) fn before_spawn(reason: FailureReason) -> Self {
1728        Self {
1729            reason,
1730            retryable: true,
1731            live_pid: None,
1732        }
1733    }
1734
1735    fn after_spawn_stopped() -> Self {
1736        Self {
1737            reason: FailureReason::ProcessStartFailed,
1738            retryable: false,
1739            live_pid: None,
1740        }
1741    }
1742
1743    fn after_spawn_live(pid: u32) -> Self {
1744        Self::after_spawn_live_with_reason(pid, FailureReason::ProcessStartFailed)
1745    }
1746
1747    fn after_spawn_live_with_reason(pid: u32, reason: FailureReason) -> Self {
1748        Self {
1749            reason,
1750            retryable: false,
1751            live_pid: Some(pid),
1752        }
1753    }
1754}
1755
1756/// A credential-free capability report. An isolated policy never treats a
1757/// native-ready result as permission to launch.
1758#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1759pub enum ProviderCapability {
1760    Ready,
1761    Unsupported,
1762    NotInstalled,
1763    PermissionDenied,
1764    ImageUnavailableOrIncompatible,
1765    DiskQuotaUnavailable,
1766    Degraded,
1767}
1768
1769impl ProviderCapability {
1770    #[must_use]
1771    pub const fn display_name(self) -> &'static str {
1772        match self {
1773            Self::Ready => "ready",
1774            Self::Unsupported => "unsupported",
1775            Self::NotInstalled => "not installed",
1776            Self::PermissionDenied => "permission denied",
1777            Self::ImageUnavailableOrIncompatible => "image unavailable or incompatible",
1778            Self::DiskQuotaUnavailable => "disk quota unavailable",
1779            Self::Degraded => "degraded",
1780        }
1781    }
1782
1783    pub(crate) fn refusal(self) -> FailureReason {
1784        FailureReason::IsolationProvider(match self {
1785            Self::Ready | Self::Degraded => IsolationProviderFailure::Degraded,
1786            Self::Unsupported => IsolationProviderFailure::Unsupported,
1787            Self::NotInstalled => IsolationProviderFailure::NotInstalled,
1788            Self::PermissionDenied => IsolationProviderFailure::PermissionDenied,
1789            Self::ImageUnavailableOrIncompatible => {
1790                IsolationProviderFailure::ImageUnavailableOrIncompatible
1791            }
1792            Self::DiskQuotaUnavailable => IsolationProviderFailure::DiskQuotaUnavailable,
1793        })
1794    }
1795}
1796
1797/// Only the closed isolation category may cross from an adapter into the
1798/// attempt journal or operator error. Arbitrary adapter text could echo JIT.
1799fn safe_isolation_reason(
1800    reason: FailureReason,
1801    fallback: IsolationProviderFailure,
1802) -> FailureReason {
1803    match reason {
1804        FailureReason::IsolationProvider(_) => reason,
1805        _ => FailureReason::IsolationProvider(fallback),
1806    }
1807}
1808
1809#[derive(Debug, Clone, PartialEq, Eq)]
1810pub struct PreparedEnvironment {
1811    attempt: AttemptId,
1812    identity: Option<EnvironmentIdentity>,
1813    /// The target whose dependency-cache namespace a native runner uses:
1814    /// `prepare` sees the policy and `start` does not.
1815    cache_target: Option<runner_manager_domain::model::ScaleTarget>,
1816}
1817
1818impl PreparedEnvironment {
1819    /// An adapter must return the identity independently recorded on its resource.
1820    #[must_use]
1821    pub fn isolated(attempt: AttemptId, identity: EnvironmentIdentity) -> Self {
1822        Self {
1823            attempt,
1824            identity: Some(identity),
1825            cache_target: None,
1826        }
1827    }
1828
1829    #[must_use]
1830    pub fn identity(&self) -> Option<&EnvironmentIdentity> {
1831        self.identity.as_ref()
1832    }
1833}
1834
1835#[derive(Debug, Clone, PartialEq, Eq)]
1836pub struct ResolvedEnvironment {
1837    pub provider_kind: Backend,
1838    pub image: ImageReference,
1839}
1840
1841/// Closed, non-secret provider diagnostics; adapters cannot attach arbitrary output.
1842#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1843pub enum ProviderDiagnostic {
1844    CapabilityUnavailable,
1845    ImageRejected,
1846    PrepareFailed,
1847    StartFailed,
1848    OwnershipMismatch,
1849    CleanupDeferred,
1850}
1851
1852#[derive(Debug, Clone, PartialEq, Eq)]
1853pub enum EnvironmentIdentity {
1854    NativeProcess(u32),
1855    Isolated {
1856        host: HostId,
1857        attempt: AttemptId,
1858        provider_kind: Backend,
1859        environment_id: String,
1860        resolved_image: ImageReference,
1861        generation: String,
1862    },
1863}
1864
1865#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1866pub enum EnvironmentState {
1867    Starting,
1868    Running,
1869    Exited,
1870    Missing,
1871}
1872
1873#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1874pub enum ProviderStop {
1875    Stopped,
1876    StillRunning,
1877}
1878
1879#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1880pub enum ProviderDestroy {
1881    Destroyed,
1882    Deferred(&'static str),
1883}
1884
1885fn identity_matches_intent(
1886    host: HostId,
1887    attempt: &RunnerAttempt,
1888    identity: &EnvironmentIdentity,
1889) -> bool {
1890    identity_same_allocation(host, attempt, identity)
1891        && match (attempt.execution(), identity) {
1892            (
1893                AttemptExecution::Isolated { environment_id, .. },
1894                EnvironmentIdentity::Isolated {
1895                    environment_id: id, ..
1896                },
1897            ) => environment_id
1898                .as_ref()
1899                .is_none_or(|journal_id| journal_id == id),
1900            _ => false,
1901        }
1902}
1903
1904fn identity_same_allocation(
1905    host: HostId,
1906    attempt: &RunnerAttempt,
1907    identity: &EnvironmentIdentity,
1908) -> bool {
1909    match (attempt.execution(), identity) {
1910        (
1911            AttemptExecution::Isolated {
1912                provider_kind,
1913                environment_id: _,
1914                resolved_image,
1915                generation,
1916            },
1917            EnvironmentIdentity::Isolated {
1918                host: owner,
1919                attempt: owner_attempt,
1920                provider_kind: kind,
1921                environment_id: _,
1922                resolved_image: image,
1923                generation: resource_generation,
1924            },
1925        ) => {
1926            *owner == host
1927                && *owner_attempt == attempt.id
1928                && provider_kind == kind
1929                && resolved_image == image
1930                && generation == resource_generation
1931        }
1932        _ => false,
1933    }
1934}
1935
1936/// The JIT value can be passed into a provider start exactly once. It must never
1937/// become a provider identity, label, argument, persisted configuration or log field.
1938pub struct OneTimeJitHandoff<'a> {
1939    config: &'a EncodedJitConfig,
1940}
1941
1942impl fmt::Debug for OneTimeJitHandoff<'_> {
1943    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1944        f.write_str("OneTimeJitHandoff([redacted])")
1945    }
1946}
1947
1948impl<'a> OneTimeJitHandoff<'a> {
1949    pub(crate) fn new(config: &'a EncodedJitConfig) -> Self {
1950        Self { config }
1951    }
1952
1953    /// Consuming the handoff prevents the same token being delivered twice by one call.
1954    pub fn consume(self) -> &'a EncodedJitConfig {
1955        self.config
1956    }
1957}
1958
1959/// Provider lifecycle and native process supervision. The default lifecycle
1960/// methods adapt the existing native spawn/recovery implementation, and refuse
1961/// every isolated allocation. Future platform adapters must implement their
1962/// own prepare/start/inspect/stop/destroy paths rather than fall back here.
1963pub trait ExecutionProvider: fmt::Debug + Send + Sync {
1964    fn probe(&self, policy: &ScalePolicy) -> ProviderCapability {
1965        if policy.execution_policy().is_native() {
1966            ProviderCapability::Ready
1967        } else {
1968            ProviderCapability::Unsupported
1969        }
1970    }
1971
1972    /// Resolve Auto and image identity before allocation or GitHub JIT. Native has no image.
1973    fn resolve(&self, policy: &ScalePolicy) -> Result<Option<ResolvedEnvironment>, FailureReason> {
1974        if policy.execution_policy().is_native() {
1975            Ok(None)
1976        } else {
1977            Err(FailureReason::Other(
1978                "execution provider unavailable".into(),
1979            ))
1980        }
1981    }
1982
1983    fn prepare(
1984        &self,
1985        attempt: &RunnerAttempt,
1986        policy: &ScalePolicy,
1987    ) -> Result<PreparedEnvironment, FailureReason> {
1988        if !attempt.execution().is_native() || !policy.execution_policy().is_native() {
1989            return Err(FailureReason::Other(
1990                "execution provider unavailable".into(),
1991            ));
1992        }
1993        Ok(PreparedEnvironment {
1994            attempt: attempt.id,
1995            identity: None,
1996            cache_target: None,
1997        })
1998    }
1999
2000    fn start(
2001        &self,
2002        prepared: PreparedEnvironment,
2003        attempt: &RunnerAttempt,
2004        handoff: OneTimeJitHandoff<'_>,
2005    ) -> Result<EnvironmentIdentity, ProcessStartFailure> {
2006        if prepared.attempt != attempt.id || !attempt.execution().is_native() {
2007            return Err(ProcessStartFailure::before_spawn(FailureReason::Other(
2008                "execution provider identity mismatch".into(),
2009            )));
2010        }
2011        self.spawn(attempt, handoff.consume())
2012            .map(EnvironmentIdentity::NativeProcess)
2013    }
2014
2015    fn inspect(&self, attempt: &RunnerAttempt) -> Result<EnvironmentState, FailureReason> {
2016        if !attempt.execution().is_native() {
2017            return Err(FailureReason::Other(
2018                "execution provider unavailable".into(),
2019            ));
2020        }
2021        self.is_alive(attempt).map(|alive| {
2022            if alive {
2023                EnvironmentState::Running
2024            } else {
2025                EnvironmentState::Missing
2026            }
2027        })
2028    }
2029
2030    fn stop(&self, attempt: &RunnerAttempt) -> Result<ProviderStop, FailureReason> {
2031        if !attempt.execution().is_native() {
2032            return Err(FailureReason::Other(
2033                "execution provider unavailable".into(),
2034            ));
2035        }
2036        self.terminate(attempt).map(|()| ProviderStop::Stopped)
2037    }
2038
2039    fn destroy(&self, attempt: &RunnerAttempt) -> Result<ProviderDestroy, FailureReason> {
2040        if !attempt.execution().is_native() {
2041            return Err(FailureReason::Other(
2042                "execution provider unavailable".into(),
2043            ));
2044        }
2045        // Native termination is already settled by the existing recovery
2046        // decision before workspace cleanup. There is no separate sandbox
2047        // resource to destroy, and repeating an observation here changes the
2048        // established native cleanup behaviour.
2049        Ok(ProviderDestroy::Destroyed)
2050    }
2051
2052    fn recover(&self, attempt: &RunnerAttempt) -> Result<EnvironmentState, FailureReason> {
2053        self.inspect(attempt)
2054    }
2055
2056    fn enumerate_owned(&self, _host_id: HostId) -> Vec<EnvironmentIdentity> {
2057        Vec::new() // Native processes have no independent provider resources.
2058    }
2059
2060    fn diagnostics(&self, _attempt: &RunnerAttempt) -> Vec<ProviderDiagnostic> {
2061        Vec::new() // No job or JIT output enters provider metadata.
2062    }
2063
2064    /// Independent resource metadata must match the journal before destructive calls.
2065    fn owns(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
2066        Ok(attempt.execution().is_native())
2067    }
2068
2069    fn spawn(
2070        &self,
2071        attempt: &RunnerAttempt,
2072        config: &EncodedJitConfig,
2073    ) -> Result<u32, ProcessStartFailure>;
2074    fn is_alive(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason>;
2075    /// Durable identity observed for a process that spawned before the
2076    /// `starting` journal write survived.
2077    fn recovered_pid(&self, attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason>;
2078    /// True only for a child this invocation owned and reaped with a successful
2079    /// exit status.  A recovered process that is merely gone answers false.
2080    fn completed_successfully(&self, attempt: &RunnerAttempt) -> bool;
2081    fn record_terminate_intent(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason>;
2082    fn has_terminate_intent(&self, attempt: &RunnerAttempt) -> bool;
2083    fn terminate(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason>;
2084}
2085
2086/// Compatibility name for existing native process tests and callers.
2087pub use ExecutionProvider as ProcessSupervisor;
2088
2089/// Native process supervision.  The start token is stored beside the runtime;
2090/// recovery never trusts a recycled PID merely because SQLite contains it.
2091#[derive(Debug, Default)]
2092pub struct NativeProcesses {
2093    children: Mutex<BTreeMap<AttemptId, ChildProcess>>,
2094    successful_exits: Mutex<BTreeMap<AttemptId, bool>>,
2095    /// The host's `runner.env`, read at every launch so that `host env set`
2096    /// reaches the next runner without a daemon restart. `None` applies none.
2097    runner_env_file: Option<PathBuf>,
2098    /// Persistent dependency caches. `None` gives runners none.
2099    dependency_caches: Option<DependencyCaches>,
2100    #[cfg(test)]
2101    post_spawn_faults: Mutex<VecDeque<PostSpawnBoundary>>,
2102    #[cfg(test)]
2103    post_spawn_reaps: std::sync::atomic::AtomicUsize,
2104    #[cfg(test)]
2105    post_spawn_stop_failures: std::sync::atomic::AtomicUsize,
2106    #[cfg(test)]
2107    use_long_lived_test_listener: std::sync::atomic::AtomicBool,
2108}
2109
2110/// Production provider set. Native and isolated allocations are routed by the
2111/// immutable execution kind in the policy/journal; an unavailable isolated
2112/// backend can never fall through to native process launch.
2113#[derive(Debug)]
2114pub struct PlatformExecutionProvider {
2115    native: NativeProcesses,
2116    oci: OciProcesses,
2117    windows_hyper_v: WindowsHyperVContainers,
2118}
2119
2120impl PlatformExecutionProvider {
2121    #[must_use]
2122    pub fn new(host_id: HostId) -> Self {
2123        Self {
2124            native: NativeProcesses::new(),
2125            oci: OciProcesses::new(host_id),
2126            windows_hyper_v: WindowsHyperVContainers::new(host_id),
2127        }
2128    }
2129
2130    /// Applies the host's `runner.env` at `path` to every native runner.
2131    /// Isolated runners do not get it: their environment is the image's.
2132    #[must_use]
2133    pub fn with_runner_env_file(mut self, path: PathBuf) -> Self {
2134        self.native = self.native.with_runner_env_file(path);
2135        self
2136    }
2137
2138    /// Gives every native runner persistent dependency caches. Isolated
2139    /// runners do not get them: their providers mount no host directory.
2140    #[must_use]
2141    pub fn with_dependency_caches(mut self, caches: DependencyCaches) -> Self {
2142        self.native = self.native.with_dependency_caches(caches);
2143        self
2144    }
2145
2146    fn isolated_provider(&self, backend: Backend) -> Option<&dyn ExecutionProvider> {
2147        match backend {
2148            Backend::Auto => {
2149                #[cfg(target_os = "windows")]
2150                {
2151                    Some(&self.windows_hyper_v)
2152                }
2153                #[cfg(not(target_os = "windows"))]
2154                {
2155                    Some(&self.oci)
2156                }
2157            }
2158            Backend::Oci => Some(&self.oci),
2159            Backend::WindowsHyperVContainer => Some(&self.windows_hyper_v),
2160            Backend::VirtualMachine => None,
2161        }
2162    }
2163
2164    fn provider_for_attempt(&self, attempt: &RunnerAttempt) -> Option<&dyn ExecutionProvider> {
2165        match attempt.execution() {
2166            AttemptExecution::Native { .. } => Some(&self.native),
2167            AttemptExecution::Isolated { provider_kind, .. } => {
2168                self.isolated_provider(*provider_kind)
2169            }
2170        }
2171    }
2172
2173    fn required_provider_for_attempt(
2174        &self,
2175        attempt: &RunnerAttempt,
2176    ) -> Result<&dyn ExecutionProvider, FailureReason> {
2177        self.provider_for_attempt(attempt)
2178            .ok_or_else(|| FailureReason::Other("execution provider unavailable".into()))
2179    }
2180}
2181
2182impl ExecutionProvider for PlatformExecutionProvider {
2183    fn probe(&self, policy: &ScalePolicy) -> ProviderCapability {
2184        match policy.execution_policy() {
2185            runner_manager_domain::execution::ExecutionPolicy::Native => self.native.probe(policy),
2186            runner_manager_domain::execution::ExecutionPolicy::Isolated { backend, .. } => self
2187                .isolated_provider(*backend)
2188                .map_or(ProviderCapability::Unsupported, |provider| {
2189                    provider.probe(policy)
2190                }),
2191        }
2192    }
2193
2194    fn resolve(&self, policy: &ScalePolicy) -> Result<Option<ResolvedEnvironment>, FailureReason> {
2195        match policy.execution_policy() {
2196            runner_manager_domain::execution::ExecutionPolicy::Native => {
2197                self.native.resolve(policy)
2198            }
2199            runner_manager_domain::execution::ExecutionPolicy::Isolated { backend, .. } => self
2200                .isolated_provider(*backend)
2201                .ok_or_else(|| FailureReason::Other("execution provider unavailable".into()))?
2202                .resolve(policy),
2203        }
2204    }
2205
2206    fn prepare(
2207        &self,
2208        attempt: &RunnerAttempt,
2209        policy: &ScalePolicy,
2210    ) -> Result<PreparedEnvironment, FailureReason> {
2211        match policy.execution_policy() {
2212            runner_manager_domain::execution::ExecutionPolicy::Native => {
2213                self.native.prepare(attempt, policy)
2214            }
2215            runner_manager_domain::execution::ExecutionPolicy::Isolated { backend, .. } => self
2216                .isolated_provider(*backend)
2217                .ok_or_else(|| FailureReason::Other("execution provider unavailable".into()))?
2218                .prepare(attempt, policy),
2219        }
2220    }
2221
2222    fn start(
2223        &self,
2224        prepared: PreparedEnvironment,
2225        attempt: &RunnerAttempt,
2226        handoff: OneTimeJitHandoff<'_>,
2227    ) -> Result<EnvironmentIdentity, ProcessStartFailure> {
2228        self.provider_for_attempt(attempt)
2229            .ok_or_else(|| {
2230                ProcessStartFailure::before_spawn(FailureReason::Other(
2231                    "execution provider unavailable".into(),
2232                ))
2233            })?
2234            .start(prepared, attempt, handoff)
2235    }
2236
2237    fn inspect(&self, attempt: &RunnerAttempt) -> Result<EnvironmentState, FailureReason> {
2238        self.required_provider_for_attempt(attempt)?
2239            .inspect(attempt)
2240    }
2241
2242    fn stop(&self, attempt: &RunnerAttempt) -> Result<ProviderStop, FailureReason> {
2243        self.required_provider_for_attempt(attempt)?.stop(attempt)
2244    }
2245
2246    fn destroy(&self, attempt: &RunnerAttempt) -> Result<ProviderDestroy, FailureReason> {
2247        self.required_provider_for_attempt(attempt)?
2248            .destroy(attempt)
2249    }
2250
2251    fn recover(&self, attempt: &RunnerAttempt) -> Result<EnvironmentState, FailureReason> {
2252        self.required_provider_for_attempt(attempt)?
2253            .recover(attempt)
2254    }
2255
2256    fn enumerate_owned(&self, host_id: HostId) -> Vec<EnvironmentIdentity> {
2257        let mut owned = self.oci.enumerate_owned(host_id);
2258        owned.extend(self.windows_hyper_v.enumerate_owned(host_id));
2259        owned
2260    }
2261
2262    fn diagnostics(&self, attempt: &RunnerAttempt) -> Vec<ProviderDiagnostic> {
2263        self.provider_for_attempt(attempt)
2264            .map_or_else(Vec::new, |provider| provider.diagnostics(attempt))
2265    }
2266
2267    fn owns(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
2268        self.required_provider_for_attempt(attempt)?.owns(attempt)
2269    }
2270
2271    fn spawn(
2272        &self,
2273        attempt: &RunnerAttempt,
2274        config: &EncodedJitConfig,
2275    ) -> Result<u32, ProcessStartFailure> {
2276        if !attempt.execution().is_native() {
2277            return Err(ProcessStartFailure::before_spawn(FailureReason::Other(
2278                "native process launch refused for an isolated allocation".into(),
2279            )));
2280        }
2281        self.native.spawn(attempt, config)
2282    }
2283
2284    fn is_alive(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
2285        self.required_provider_for_attempt(attempt)?
2286            .is_alive(attempt)
2287    }
2288
2289    fn recovered_pid(&self, attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason> {
2290        self.required_provider_for_attempt(attempt)?
2291            .recovered_pid(attempt)
2292    }
2293
2294    fn completed_successfully(&self, attempt: &RunnerAttempt) -> bool {
2295        self.provider_for_attempt(attempt)
2296            .is_some_and(|provider| provider.completed_successfully(attempt))
2297    }
2298
2299    fn record_terminate_intent(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason> {
2300        self.required_provider_for_attempt(attempt)?
2301            .record_terminate_intent(attempt)
2302    }
2303
2304    fn has_terminate_intent(&self, attempt: &RunnerAttempt) -> bool {
2305        self.provider_for_attempt(attempt)
2306            .is_some_and(|provider| provider.has_terminate_intent(attempt))
2307    }
2308
2309    fn terminate(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason> {
2310        self.required_provider_for_attempt(attempt)?
2311            .terminate(attempt)
2312    }
2313}
2314
2315#[cfg(test)]
2316#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2317enum PostSpawnBoundary {
2318    HandoffDelete,
2319    IdentitySerialize,
2320    IdentityWrite,
2321    ChildMapInsert,
2322}
2323
2324impl NativeProcesses {
2325    #[must_use]
2326    pub fn new() -> Self {
2327        Self::default()
2328    }
2329
2330    /// Applies the host's `runner.env` at `path` to every runner launched.
2331    #[must_use]
2332    pub fn with_runner_env_file(mut self, path: PathBuf) -> Self {
2333        self.runner_env_file = Some(path);
2334        self
2335    }
2336
2337    /// Gives every runner launched persistent dependency caches.
2338    #[must_use]
2339    pub fn with_dependency_caches(mut self, caches: DependencyCaches) -> Self {
2340        self.dependency_caches = Some(caches);
2341        self
2342    }
2343
2344    /// The cache variables for `attempt`, when `prepare` named its target.
2345    fn cache_env(
2346        &self,
2347        attempt: &RunnerAttempt,
2348        target: Option<&runner_manager_domain::model::ScaleTarget>,
2349        host_env: &RunnerEnv,
2350    ) -> Vec<(&'static str, std::ffi::OsString)> {
2351        match (&self.dependency_caches, target) {
2352            (Some(caches), Some(target)) => {
2353                caches.for_launch(target, attempt.runtime_path(), host_env)
2354            }
2355            _ => Vec::new(),
2356        }
2357    }
2358
2359    /// Starts `Runner.Listener` for `attempt`, with the dependency caches of
2360    /// `cache_target` when there is one.
2361    fn spawn_listener(
2362        &self,
2363        attempt: &RunnerAttempt,
2364        config: &EncodedJitConfig,
2365        cache_target: Option<&runner_manager_domain::model::ScaleTarget>,
2366    ) -> Result<u32, ProcessStartFailure> {
2367        let handoff = RestrictiveHandoff::create(
2368            attempt.runtime_path(),
2369            SecretString::from(config.expose().to_owned()),
2370        )
2371        .map_err(|_| ProcessStartFailure::before_spawn(FailureReason::ProcessStartFailed))?;
2372        #[cfg(windows)]
2373        let program = attempt
2374            .runtime_path()
2375            .join("bin")
2376            .join("Runner.Listener.exe");
2377        #[cfg(not(windows))]
2378        let program = attempt.runtime_path().join("bin").join("Runner.Listener");
2379        // Checked after the handoff exists on purpose: the error path below is
2380        // a real post-handoff launch failure, and unwinding must delete it.
2381        if !program.is_file() {
2382            return Err(ProcessStartFailure::before_spawn(
2383                FailureReason::ProcessStartFailed,
2384            ));
2385        }
2386        let host_env = self.host_env().map_err(ProcessStartFailure::before_spawn)?;
2387        let caches = self.cache_env(attempt, cache_target, &host_env);
2388        #[cfg(test)]
2389        let spec = if self
2390            .use_long_lived_test_listener
2391            .load(std::sync::atomic::Ordering::SeqCst)
2392        {
2393            SpawnSpec::new(program)
2394                .args([
2395                    "--ignored",
2396                    "--exact",
2397                    "lifecycle::tests::long_lived_native_listener_helper",
2398                    "--nocapture",
2399                ])
2400                .env(
2401                    "RUNNER_MANAGER_TEST_LISTENER_READY",
2402                    attempt.runtime_path().join(TEST_LISTENER_READY),
2403                )
2404                .working_dir(attempt.runtime_path())
2405        } else {
2406            runner_listener_spec(program, attempt.runtime_path(), caches, &host_env)
2407        };
2408        #[cfg(not(test))]
2409        let spec = runner_listener_spec(program, attempt.runtime_path(), caches, &host_env);
2410        let child = spec
2411            .spawn_runner_with_handoff(&handoff)
2412            .map_err(|_| ProcessStartFailure::before_spawn(FailureReason::ProcessStartFailed))?;
2413        // The payload is gone before any state saying "starting" is persisted.
2414        #[cfg(test)]
2415        if self.faults_at(PostSpawnBoundary::HandoffDelete) {
2416            drop(handoff);
2417            return Err(self.abort_spawned_child(child, attempt, false));
2418        }
2419        if handoff.delete().is_err() {
2420            return Err(self.abort_spawned_child(child, attempt, false));
2421        }
2422        #[cfg(test)]
2423        if self.faults_at(PostSpawnBoundary::IdentitySerialize) {
2424            return Err(self.abort_spawned_child(child, attempt, false));
2425        }
2426        let identity = match serde_json::to_vec(child.identity()) {
2427            Ok(identity) => identity,
2428            Err(_) => {
2429                return Err(self.abort_spawned_child(child, attempt, false));
2430            }
2431        };
2432        if self.persist_identity(attempt, &identity).is_err() {
2433            return Err(self.abort_spawned_child(child, attempt, true));
2434        }
2435        let pid = child.pid();
2436        #[cfg(test)]
2437        if self.faults_at(PostSpawnBoundary::ChildMapInsert) {
2438            return Err(self.abort_spawned_child(child, attempt, true));
2439        }
2440        let mut children = self
2441            .children
2442            .lock()
2443            .unwrap_or_else(std::sync::PoisonError::into_inner);
2444        children.insert(attempt.id, child);
2445        Ok(pid)
2446    }
2447
2448    /// The host's `runner.env`, read now.
2449    ///
2450    /// A file that cannot be read or parsed fails the launch rather than
2451    /// starting a runner without the environment the operator configured.
2452    /// [`ExecutionProvider::prepare`] asks first, before a JIT runner is
2453    /// registered, so a broken file costs no registration. The reason names
2454    /// the file and the line or variable, never a value.
2455    fn host_env(&self) -> Result<RunnerEnv, FailureReason> {
2456        let Some(path) = &self.runner_env_file else {
2457            return Ok(RunnerEnv::default());
2458        };
2459        RunnerEnv::load(path).map_err(|error| {
2460            tracing::warn!(
2461                reason = "runner_env_file_invalid",
2462                "runner.env could not be applied, so no runner starts until it is fixed: {error}"
2463            );
2464            FailureReason::Other(format!(
2465                "runner.env cannot be applied ({error}); fix it with `runner-manager host env`"
2466            ))
2467        })
2468    }
2469
2470    #[cfg(test)]
2471    fn fail_post_spawn_at(&self, boundary: PostSpawnBoundary) {
2472        self.post_spawn_faults.lock().unwrap().push_back(boundary);
2473    }
2474
2475    #[cfg(test)]
2476    fn faults_at(&self, boundary: PostSpawnBoundary) -> bool {
2477        let mut faults = self.post_spawn_faults.lock().unwrap();
2478        if faults.front() == Some(&boundary) {
2479            faults.pop_front();
2480            true
2481        } else {
2482            false
2483        }
2484    }
2485
2486    #[cfg(test)]
2487    fn fail_post_spawn_stops(&self, count: usize) {
2488        self.post_spawn_stop_failures
2489            .fetch_add(count, std::sync::atomic::Ordering::SeqCst);
2490    }
2491
2492    #[cfg(test)]
2493    fn fail_next_post_spawn_stop(&self) {
2494        self.fail_post_spawn_stops(1);
2495    }
2496
2497    #[cfg(test)]
2498    fn use_long_lived_test_listener(&self) {
2499        self.use_long_lived_test_listener
2500            .store(true, std::sync::atomic::Ordering::SeqCst);
2501    }
2502
2503    fn stop_spawned_child(&self, child: &mut ChildProcess) -> Result<(), FailureReason> {
2504        #[cfg(test)]
2505        if self
2506            .post_spawn_stop_failures
2507            .fetch_update(
2508                std::sync::atomic::Ordering::SeqCst,
2509                std::sync::atomic::Ordering::SeqCst,
2510                |left| if left > 0 { Some(left - 1) } else { None },
2511            )
2512            .is_ok()
2513        {
2514            return Err(FailureReason::Other("injected runner stop failure".into()));
2515        }
2516        child
2517            .stop(Duration::from_secs(1))
2518            .map(|_| ())
2519            .map_err(|_| FailureReason::Other("spawned runner process could not be stopped".into()))
2520    }
2521
2522    fn abort_spawned_child(
2523        &self,
2524        mut child: ChildProcess,
2525        attempt: &RunnerAttempt,
2526        remove_identity: bool,
2527    ) -> ProcessStartFailure {
2528        let mut reaped = self.stop_spawned_child(&mut child).is_ok();
2529        if reaped {
2530            if remove_identity {
2531                Self::remove_identity_files(attempt);
2532            }
2533        } else {
2534            // A failed stop is not a failed attempt yet.  Persist enough truth
2535            // for crash recovery, and retain the owned child when possible.
2536            let identity_durable =
2537                serde_json::to_vec(child.identity())
2538                    .ok()
2539                    .is_some_and(|identity| {
2540                        self.persist_identity(attempt, &identity).is_ok()
2541                            || self.persist_fallback_identity(attempt, &identity).is_ok()
2542                    });
2543            if !identity_durable {
2544                // Returning a live PID as though recovery were complete would
2545                // make the next boot trust a recyclable PID. Reaping is bounded;
2546                // if it cannot finish, the durable `starting` journal entry is
2547                // deliberately unresolved on restart and blocks new launches.
2548                for _ in 1..MAX_POST_SPAWN_STOP_ATTEMPTS {
2549                    if self.stop_spawned_child(&mut child).is_ok() {
2550                        reaped = true;
2551                        break;
2552                    }
2553                }
2554                if !reaped {
2555                    // The attempt journal will durably record `starting` and
2556                    // its PID. Recovery treats a missing full identity as
2557                    // unresolved and starts nothing, so bounded stop failure
2558                    // cannot turn into either a hang or a duplicate runner.
2559                    let pid = child.pid();
2560                    let marker = write_durable_file(
2561                        &Self::unresolved_process_path(attempt),
2562                        pid.to_string().as_bytes(),
2563                    );
2564                    self.children
2565                        .lock()
2566                        .unwrap_or_else(std::sync::PoisonError::into_inner)
2567                        .insert(attempt.id, child);
2568                    let reason = if marker.is_ok() {
2569                        FailureReason::Other(
2570                            "spawn cleanup exhausted its bounded stop attempts; the live process remains under durable unresolved supervision"
2571                                .into(),
2572                        )
2573                    } else {
2574                        FailureReason::Other(
2575                            "spawn cleanup exhausted its bounded stop attempts and the unresolved-process marker could not be journalled"
2576                                .into(),
2577                        )
2578                    };
2579                    return ProcessStartFailure::after_spawn_live_with_reason(pid, reason);
2580                }
2581            } else {
2582                let pid = child.pid();
2583                self.children
2584                    .lock()
2585                    .unwrap_or_else(std::sync::PoisonError::into_inner)
2586                    .insert(attempt.id, child);
2587                return ProcessStartFailure::after_spawn_live(pid);
2588            }
2589        }
2590        #[cfg(test)]
2591        if reaped {
2592            self.post_spawn_reaps
2593                .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2594        }
2595        #[cfg(not(test))]
2596        let _ = reaped;
2597        ProcessStartFailure::after_spawn_stopped()
2598    }
2599
2600    fn identity_path(attempt: &RunnerAttempt) -> PathBuf {
2601        attempt.runtime_path().join(IDENTITY_FILE)
2602    }
2603
2604    fn fallback_identity_path(attempt: &RunnerAttempt) -> PathBuf {
2605        attempt.runtime_path().join(FALLBACK_IDENTITY_FILE)
2606    }
2607
2608    fn unresolved_process_path(attempt: &RunnerAttempt) -> PathBuf {
2609        attempt.runtime_path().join(UNRESOLVED_PROCESS_FILE)
2610    }
2611
2612    fn remove_identity_files(attempt: &RunnerAttempt) {
2613        let _ = fs::remove_file(Self::identity_path(attempt));
2614        let _ = fs::remove_file(Self::fallback_identity_path(attempt));
2615        let _ = fs::remove_file(Self::unresolved_process_path(attempt));
2616    }
2617
2618    fn persist_identity(&self, attempt: &RunnerAttempt, bytes: &[u8]) -> std::io::Result<()> {
2619        self.persist_identity_at(&Self::identity_path(attempt), bytes)
2620    }
2621
2622    fn persist_fallback_identity(
2623        &self,
2624        attempt: &RunnerAttempt,
2625        bytes: &[u8],
2626    ) -> std::io::Result<()> {
2627        self.persist_identity_at(&Self::fallback_identity_path(attempt), bytes)
2628    }
2629
2630    fn persist_identity_at(&self, path: &Path, bytes: &[u8]) -> std::io::Result<()> {
2631        #[cfg(test)]
2632        if self.faults_at(PostSpawnBoundary::IdentityWrite) {
2633            return Err(std::io::Error::other("injected identity write failure"));
2634        }
2635        write_durable_file(path, bytes)
2636    }
2637
2638    fn intent_path(attempt: &RunnerAttempt) -> PathBuf {
2639        attempt.runtime_path().join(TERMINATE_INTENT_FILE)
2640    }
2641
2642    fn read_identity(attempt: &RunnerAttempt) -> Result<Option<ProcessIdentity>, FailureReason> {
2643        match Self::read_identity_at(&Self::identity_path(attempt))? {
2644            Some(identity) => Ok(Some(identity)),
2645            None => Self::read_identity_at(&Self::fallback_identity_path(attempt)),
2646        }
2647    }
2648
2649    fn read_identity_at(path: &Path) -> Result<Option<ProcessIdentity>, FailureReason> {
2650        match fs::read(path) {
2651            Ok(bytes) => serde_json::from_slice(&bytes)
2652                .map(Some)
2653                .map_err(|_| FailureReason::Other("process identity journal is unreadable".into())),
2654            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
2655            Err(_) => Err(FailureReason::Other(
2656                "process identity journal could not be read".into(),
2657            )),
2658        }
2659    }
2660}
2661
2662impl ProcessSupervisor for NativeProcesses {
2663    fn prepare(
2664        &self,
2665        attempt: &RunnerAttempt,
2666        policy: &ScalePolicy,
2667    ) -> Result<PreparedEnvironment, FailureReason> {
2668        if !attempt.execution().is_native() || !policy.execution_policy().is_native() {
2669            return Err(FailureReason::Other(
2670                "execution provider unavailable".into(),
2671            ));
2672        }
2673        self.host_env()?;
2674        // Asked here, before the JIT registration, for the reason `host_env`
2675        // is: a broken file must cost no registration.
2676        let cache_target = match &self.dependency_caches {
2677            Some(caches) => {
2678                caches.config()?;
2679                Some(policy.target.clone())
2680            }
2681            None => None,
2682        };
2683        Ok(PreparedEnvironment {
2684            attempt: attempt.id,
2685            identity: None,
2686            cache_target,
2687        })
2688    }
2689
2690    fn start(
2691        &self,
2692        prepared: PreparedEnvironment,
2693        attempt: &RunnerAttempt,
2694        handoff: OneTimeJitHandoff<'_>,
2695    ) -> Result<EnvironmentIdentity, ProcessStartFailure> {
2696        if prepared.attempt != attempt.id || !attempt.execution().is_native() {
2697            return Err(ProcessStartFailure::before_spawn(FailureReason::Other(
2698                "execution provider identity mismatch".into(),
2699            )));
2700        }
2701        self.spawn_listener(attempt, handoff.consume(), prepared.cache_target.as_ref())
2702            .map(EnvironmentIdentity::NativeProcess)
2703    }
2704
2705    fn spawn(
2706        &self,
2707        attempt: &RunnerAttempt,
2708        config: &EncodedJitConfig,
2709    ) -> Result<u32, ProcessStartFailure> {
2710        self.spawn_listener(attempt, config, None)
2711    }
2712
2713    fn is_alive(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
2714        let mut children = self
2715            .children
2716            .lock()
2717            .unwrap_or_else(std::sync::PoisonError::into_inner);
2718        if let Some(child) = children.get_mut(&attempt.id) {
2719            return match child
2720                .try_exit_status()
2721                .map_err(|_| FailureReason::Other("runner process could not be observed".into()))?
2722            {
2723                None => Ok(true),
2724                Some(status) => {
2725                    if let Ok(mut exits) = self.successful_exits.lock() {
2726                        exits.insert(attempt.id, status.success());
2727                    }
2728                    Ok(false)
2729                }
2730            };
2731        }
2732        let Some(identity) = Self::read_identity(attempt)? else {
2733            if attempt.process_id().is_some() || Self::unresolved_process_path(attempt).is_file() {
2734                return Err(FailureReason::Other(
2735                    "runner process identity is missing; refusing recovery until the process is resolved"
2736                        .into(),
2737                ));
2738            }
2739            return Ok(false);
2740        };
2741        match identity.recheck() {
2742            Ok(Adoption::Live) => Ok(true),
2743            Ok(Adoption::Gone | Adoption::PidRecycled { .. }) => Ok(false),
2744            Err(_) => Ok(false),
2745        }
2746    }
2747
2748    fn recovered_pid(&self, attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason> {
2749        Ok(Self::read_identity(attempt)?.map(|identity| identity.pid()))
2750    }
2751
2752    fn completed_successfully(&self, attempt: &RunnerAttempt) -> bool {
2753        self.successful_exits
2754            .lock()
2755            .ok()
2756            .and_then(|exits| exits.get(&attempt.id).copied())
2757            .unwrap_or(false)
2758    }
2759
2760    fn record_terminate_intent(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason> {
2761        let path = Self::intent_path(attempt);
2762        write_durable_file(&path, b"registration-timeout\n")
2763            .map_err(|_| FailureReason::Other("terminate intent could not be journalled".into()))
2764    }
2765
2766    fn has_terminate_intent(&self, attempt: &RunnerAttempt) -> bool {
2767        Self::intent_path(attempt).is_file()
2768    }
2769
2770    fn terminate(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason> {
2771        let mut children = self
2772            .children
2773            .lock()
2774            .unwrap_or_else(std::sync::PoisonError::into_inner);
2775        if let Some(child) = children.get_mut(&attempt.id) {
2776            child
2777                .stop(Duration::from_secs(10))
2778                .map_err(|_| FailureReason::Other("runner process could not be stopped".into()))?;
2779            return Ok(());
2780        }
2781        let Some(identity) = Self::read_identity(attempt)? else {
2782            return Ok(());
2783        };
2784        match identity
2785            .terminate(Duration::from_secs(10))
2786            .map_err(|_| FailureReason::Other("runner process could not be stopped".into()))?
2787        {
2788            Termination::Terminated | Termination::AlreadyGone => Ok(()),
2789            Termination::RefusedPidRecycled { .. } => Err(FailureReason::Other(
2790                "runner PID was recycled; refusing to signal it".into(),
2791            )),
2792        }
2793    }
2794}
2795
2796pub struct LifecyclePorts {
2797    pub store: Arc<dyn Store>,
2798    pub github: Arc<dyn LifecycleGithub>,
2799    pub packages: Arc<dyn RuntimePackages>,
2800    pub processes: Arc<dyn ExecutionProvider>,
2801    pub clock: Arc<dyn Clock>,
2802    pub demand: Arc<dyn DemandPersistence>,
2803    pub delay: Arc<dyn RetryDelay>,
2804    pub events: Arc<dyn AttemptEventSink>,
2805    pub reconcile_events: Arc<dyn EventSink>,
2806}
2807
2808impl fmt::Debug for LifecyclePorts {
2809    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2810        f.debug_struct("LifecyclePorts")
2811            .field("store", &self.store)
2812            .field("github", &self.github)
2813            .field("packages", &self.packages)
2814            .field("processes", &self.processes)
2815            .finish_non_exhaustive()
2816    }
2817}
2818
2819#[derive(Debug, thiserror::Error)]
2820pub enum LifecycleError {
2821    #[error("attempt journal operation failed")]
2822    Journal,
2823    #[error("attempt {0} is not in the journal")]
2824    Missing(AttemptId),
2825    #[error("attempt lifecycle transition was refused")]
2826    Transition,
2827    #[error("startup recovery has not completed")]
2828    RecoveryIncomplete,
2829    /// A persistent slot could not be proven safe to scrub, so the attempt keeps
2830    /// its uncleaned state and, with it, its slot lease.
2831    ///
2832    /// Separate from [`Self::Failed`] because the two demand opposite handling
2833    /// from the same call sites. A failure aborts the pass; a quarantine must
2834    /// not, or one stuck slot would stop the host launching anything at all —
2835    /// which is precisely what `04-security-recovery.md` rules out when it says
2836    /// such an attempt "does not count as active host capacity" and "recovery
2837    /// retries the same cleanup".
2838    #[error("the persistent slot was not cleaned: {detail}")]
2839    SlotQuarantined {
2840        /// The closed-vocabulary event field; never operator or workflow text.
2841        class: &'static str,
2842        detail: String,
2843    },
2844    /// An ephemeral runtime is still held by a late process. Its journal state
2845    /// remains unchanged and ordinary supervision retries it, but startup
2846    /// recovery must not take unrelated repositories offline because of it.
2847    #[error("{}", FailureReason::WorkspaceCleanupDeferred)]
2848    WorkspaceCleanupDeferred,
2849    #[error("isolated environment cleanup is deferred")]
2850    EnvironmentCleanupDeferred,
2851    #[error("runner lifecycle failed: {0}")]
2852    Failed(FailureReason),
2853}
2854
2855impl LifecycleError {
2856    fn reason(&self) -> FailureReason {
2857        match self {
2858            Self::Failed(reason) => reason.clone(),
2859            Self::RecoveryIncomplete => FailureReason::Other("startup recovery incomplete".into()),
2860            Self::Journal => FailureReason::Other("attempt journal operation failed".into()),
2861            Self::Missing(_) => FailureReason::Other("attempt disappeared from the journal".into()),
2862            Self::Transition => FailureReason::Other("attempt transition was refused".into()),
2863            // Rendered through `Display` rather than a second copy of the same
2864            // sentence, so the two cannot drift apart.
2865            Self::SlotQuarantined { .. } => FailureReason::Other(self.to_string()),
2866            Self::WorkspaceCleanupDeferred => FailureReason::WorkspaceCleanupDeferred,
2867            Self::EnvironmentCleanupDeferred => FailureReason::Other(self.to_string()),
2868        }
2869    }
2870}
2871
2872/// Production implementation of e1's launcher port.
2873#[derive(Debug)]
2874pub struct LifecycleLauncher {
2875    host_id: HostId,
2876    app_paths: runner_manager_platform::paths::AppPaths,
2877    diagnostics_root: PathBuf,
2878    runner_group_id: u64,
2879    timeouts: RecoveryTimeouts,
2880    retry: RetryPolicy,
2881    cancel: CancelToken,
2882    ports: LifecyclePorts,
2883    recovery_complete: Mutex<bool>,
2884    versions: Mutex<BTreeMap<AttemptId, RunnerVersion>>,
2885    pending_replacements: Mutex<BTreeMap<AttemptId, ReplacementIntent>>,
2886    /// Backoff for cleanups [`Self::supervise`] tolerates and retries, so a
2887    /// workspace that cannot be removed is not retried and reported on every
2888    /// pass.
2889    cleanup_retries: Mutex<CleanBackoff>,
2890}
2891
2892#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2893enum ReconcileProgress {
2894    Reconciled,
2895    Deferred,
2896    Replacement {
2897        attempt: AttemptId,
2898        operation: &'static str,
2899    },
2900}
2901
2902impl LifecycleLauncher {
2903    #[must_use]
2904    pub fn new(
2905        host_id: HostId,
2906        app_paths: runner_manager_platform::paths::AppPaths,
2907        diagnostics_root: impl Into<PathBuf>,
2908        runner_group_id: u64,
2909        timeouts: RecoveryTimeouts,
2910        retry: RetryPolicy,
2911        ports: LifecyclePorts,
2912    ) -> Self {
2913        Self {
2914            host_id,
2915            app_paths,
2916            diagnostics_root: diagnostics_root.into(),
2917            runner_group_id,
2918            timeouts,
2919            retry,
2920            cancel: CancelToken::new(),
2921            ports,
2922            recovery_complete: Mutex::new(false),
2923            versions: Mutex::new(BTreeMap::new()),
2924            pending_replacements: Mutex::new(BTreeMap::new()),
2925            cleanup_retries: Mutex::new(CleanBackoff::default()),
2926        }
2927    }
2928
2929    /// Reconcile the entire journal before allowing a launch.  Unknown-policy
2930    /// attempts are left untouched rather than being acted on without an
2931    /// ownership proof.
2932    pub async fn recover_startup(
2933        &self,
2934        policies: &[ScalePolicy],
2935    ) -> Result<Vec<ReplacementIntent>, LifecycleError> {
2936        *self
2937            .recovery_complete
2938            .lock()
2939            .map_err(|_| LifecycleError::Journal)? = false;
2940        let by_id: BTreeMap<_, _> = policies.iter().map(|policy| (policy.id, policy)).collect();
2941        let attempts = self
2942            .ports
2943            .store
2944            .attempts()
2945            .map_err(|_| LifecycleError::Journal)?;
2946        let owned = self.ports.processes.enumerate_owned(self.host_id);
2947        for identity in &owned {
2948            if let EnvironmentIdentity::Isolated { attempt: id, .. } = identity
2949                && !attempts.iter().any(|attempt| {
2950                    attempt.state() != AttemptState::Cleaned
2951                        && identity_matches_intent(self.host_id, attempt, identity)
2952                })
2953            {
2954                // Enumeration is discovery, not authority to delete.
2955                self.ports
2956                    .events
2957                    .emit(AttemptEvent::QuarantinedOwnedResource { attempt: *id });
2958                tracing::warn!(host_id = %self.host_id, attempt_id = %id, reason = "owned_isolated_resource_without_matching_journal", "provider resource quarantined for operator review");
2959            }
2960        }
2961        let mut unresolved = false;
2962        for mut attempt in attempts {
2963            let Some(policy) = by_id.get(&attempt.policy_id) else {
2964                if attempt.counts_against_capacity() {
2965                    unresolved = true;
2966                }
2967                continue;
2968            };
2969            authorize(self.host_id, policy, &attempt).map_err(|_| LifecycleError::Journal)?;
2970            let allocation_resources = owned
2971                .iter()
2972                .filter(|identity| identity_same_allocation(self.host_id, &attempt, identity))
2973                .collect::<Vec<_>>();
2974            if allocation_resources.len() > 1 {
2975                self.ports
2976                    .events
2977                    .emit(AttemptEvent::QuarantinedOwnedResource {
2978                        attempt: attempt.id,
2979                    });
2980                tracing::warn!(attempt_id = %attempt.id, reason = "duplicate_isolated_resource_ownership", "provider resources quarantined for operator review");
2981            }
2982            if attempt.state() != AttemptState::Cleaned
2983                && matches!(
2984                    attempt.execution(),
2985                    AttemptExecution::Isolated {
2986                        environment_id: None,
2987                        ..
2988                    }
2989                )
2990                && allocation_resources.len() == 1
2991                && let Some(EnvironmentIdentity::Isolated { environment_id, .. }) =
2992                    allocation_resources.first().copied()
2993            {
2994                attempt
2995                    .prepared_environment(environment_id.clone())
2996                    .map_err(|_| LifecycleError::Transition)?;
2997                if attempt.state() == AttemptState::Preparing {
2998                    attempt
2999                        .mark_prepared(self.ports.clock.now())
3000                        .map_err(|_| LifecycleError::Transition)?;
3001                }
3002                self.record(&attempt)?;
3003            }
3004            match self.reconcile_one(policy, attempt).await? {
3005                ReconcileProgress::Deferred => {
3006                    if policy.execution_policy().is_native() {
3007                        unresolved = true;
3008                    }
3009                }
3010                ReconcileProgress::Replacement { attempt, operation } => {
3011                    self.pending_replacements
3012                        .lock()
3013                        .map_err(|_| LifecycleError::Journal)?
3014                        .insert(
3015                            attempt,
3016                            ReplacementIntent {
3017                                policy: policy.id,
3018                                previous_attempt: attempt,
3019                                operation,
3020                            },
3021                        );
3022                }
3023                ReconcileProgress::Reconciled => {}
3024            }
3025        }
3026        if unresolved {
3027            return Err(LifecycleError::RecoveryIncomplete);
3028        }
3029        *self
3030            .recovery_complete
3031            .lock()
3032            .map_err(|_| LifecycleError::Journal)? = true;
3033        Ok(self
3034            .pending_replacements
3035            .lock()
3036            .map_err(|_| LifecycleError::Journal)?
3037            .values()
3038            .copied()
3039            .collect())
3040    }
3041
3042    /// Supervise all attempts of one policy during an ordinary poll.
3043    pub async fn supervise(
3044        &self,
3045        policy: &ScalePolicy,
3046    ) -> Result<Vec<ReplacementIntent>, LifecycleError> {
3047        let mut replacements = Vec::new();
3048        self.pending_replacements
3049            .lock()
3050            .map_err(|_| LifecycleError::Journal)?
3051            .retain(|_, intent| {
3052                if intent.policy == policy.id {
3053                    replacements.push(*intent);
3054                    false
3055                } else {
3056                    true
3057                }
3058            });
3059        let attempts = self
3060            .ports
3061            .store
3062            .attempts_for_policy(policy.id)
3063            .map_err(|_| LifecycleError::Journal)?;
3064        for attempt in attempts {
3065            authorize(self.host_id, policy, &attempt).map_err(|_| LifecycleError::Journal)?;
3066            if let ReconcileProgress::Replacement { attempt, operation } =
3067                self.reconcile_one(policy, attempt).await?
3068            {
3069                replacements.push(ReplacementIntent {
3070                    policy: policy.id,
3071                    previous_attempt: attempt,
3072                    operation,
3073                });
3074            }
3075        }
3076        Ok(replacements)
3077    }
3078
3079    async fn reconcile_one(
3080        &self,
3081        policy: &ScalePolicy,
3082        mut attempt: RunnerAttempt,
3083    ) -> Result<ReconcileProgress, LifecycleError> {
3084        if !attempt.execution().is_native() {
3085            return self.reconcile_isolated_one(policy, attempt).await;
3086        }
3087        if attempt.state() == AttemptState::Cleaned {
3088            // A child of Runner.Worker can outlive the runner process briefly.
3089            // If it recreates `_work` after `clean_attempt` removed the tree but
3090            // before the journal's final write, the journal truthfully reaches
3091            // `cleaned` and the directory appears again afterwards. Cleaned
3092            // attempts used to return here forever, making that late residue
3093            // invisible. The journalled ephemeral mode is the ownership proof:
3094            // retry the whole-tree removal, while a persistent slot deliberately
3095            // remains on disk with its retained `_work`.
3096            if matches!(attempt.workspace(), AttemptWorkspace::Ephemeral)
3097                && attempt.runtime_path().exists()
3098            {
3099                // The attempt is already terminal, has released its package
3100                // lease, and consumes no capacity. A late child may still hold
3101                // one of its recreated files open on Windows, so failure here
3102                // is residue to retry, not a reason to abort startup recovery
3103                // and take every unrelated repository offline.
3104                // Retried with backoff; the event is the one report, which the
3105                // event sink logs as a warning.
3106                let now = self.ports.clock.now();
3107                if !self.cleanup_retries().is_due(attempt.id, now) {
3108                    return Ok(ReconcileProgress::Reconciled);
3109                }
3110                match self.scrub_workspace(&attempt) {
3111                    Ok(()) => self.cleanup_retries().succeeded(attempt.id),
3112                    Err(LifecycleError::WorkspaceCleanupDeferred) => {
3113                        self.cleanup_retries().failed(attempt.id, now);
3114                        self.ports
3115                            .reconcile_events
3116                            .emit(LifecycleEvent::AttemptCleanFailed {
3117                                policy: attempt.policy_id,
3118                                attempt: attempt.id,
3119                                reason: "late_ephemeral_workspace_could_not_be_removed",
3120                            });
3121                    }
3122                    Err(error) => return Err(error),
3123                }
3124            }
3125            return Ok(ReconcileProgress::Reconciled);
3126        }
3127        if attempt.is_terminal() {
3128            self.clean_or_quarantine(&mut attempt)?;
3129            return Ok(ReconcileProgress::Reconciled);
3130        }
3131        let process_alive = self
3132            .ports
3133            .processes
3134            .inspect(&attempt)
3135            .map_err(LifecycleError::Failed)?
3136            == EnvironmentState::Running;
3137        let github = self
3138            .ports
3139            .github
3140            .observe(&policy.target, attempt.id, &self.cancel)
3141            .await;
3142
3143        // If the agent died after GitHub accepted the registration but before
3144        // the non-secret runner-id sidecar landed, inventory closes the gap.
3145        // Persist it before making any state decision so a second crash moves
3146        // the boundary forward rather than repeating it.
3147        if let Some(runner_id) = github.runner_id
3148            && read_runner_id(attempt.runtime_path()).is_none()
3149        {
3150            write_runner_id(attempt.runtime_path(), runner_id)?;
3151            self.ports
3152                .events
3153                .emit(AttemptEvent::RemoteIdentityRecovered {
3154                    attempt: attempt.id,
3155                    runner_id,
3156                });
3157        }
3158
3159        // The child identity is synced before `spawn` returns.  If the agent
3160        // crashed before the following `starting` journal write, recover that
3161        // exact PID and take the legal `jit_received -> starting` edge before
3162        // applying GitHub's authoritative idle/busy observation below.
3163        if attempt.state() == AttemptState::JitReceived
3164            && process_alive
3165            && let Some(pid) = self
3166                .ports
3167                .processes
3168                .recovered_pid(&attempt)
3169                .map_err(LifecycleError::Failed)?
3170        {
3171            attempt
3172                .started(pid, self.ports.clock.now())
3173                .map_err(|_| LifecycleError::Transition)?;
3174            self.record(&attempt)?;
3175        }
3176
3177        // What the runner's own diagnostics say about whether it ran a job.
3178        // Read before anything below can conclude the attempt, because cleanup
3179        // deletes `_diag` with the rest of the runtime directory.
3180        // A one-shot child owned by this invocation exited successfully. This
3181        // concludes the *runner attempt*, never the workflow outcome; GitHub
3182        // remains authoritative for that. GitHub's inventory is a sampled
3183        // witness: a job of a few seconds starts and ends between two passes,
3184        // and an assignment GitHub shows can be one the runner never took up.
3185        // The runner's own diagnostics decide, read before cleanup deletes
3186        // them, and GitHub's sample only decides when they say nothing. Not
3187        // while GitHub is unreachable: a registration still listed there could
3188        // not be removed, and nothing visits a concluded attempt again.
3189        if !process_alive
3190            && github.status != GithubRunnerObservation::Unreachable
3191            && matches!(
3192                attempt.state(),
3193                AttemptState::Starting | AttemptState::Idle | AttemptState::Busy
3194            )
3195            && self.ports.processes.completed_successfully(&attempt)
3196        {
3197            let state = attempt.state();
3198            let outcome = match (state, JobEvidence::of(attempt.runtime_path())) {
3199                // Without a runner id there is no edge to `busy`; the recovery
3200                // path below concludes the attempt without one.
3201                (AttemptState::Starting | AttemptState::Idle, JobEvidence::RanAJob) => {
3202                    known_runner_id(&attempt, github.runner_id)
3203                        .map(|runner_id| (Some(runner_id), AttemptOutcome::CompletedJob))
3204                }
3205                (AttemptState::Busy, JobEvidence::RanAJob) => {
3206                    Some((None, AttemptOutcome::CompletedJob))
3207                }
3208                (AttemptState::Busy, JobEvidence::NoJob) => {
3209                    Some((None, AttemptOutcome::ExitedIdleWithoutWork))
3210                }
3211                (AttemptState::Busy, JobEvidence::Unknown)
3212                    if github.status == GithubRunnerObservation::NotRegistered =>
3213                {
3214                    Some((None, AttemptOutcome::CompletedJob))
3215                }
3216                _ => None,
3217            };
3218            if let Some((walk_to_busy, outcome)) = outcome {
3219                // An ephemeral runner leaves GitHub's list a little after its
3220                // process exits; one still listed is removed here rather than
3221                // left for GitHub to time out. First, so the journal's last
3222                // intermediate state and the conclusion are written back to back:
3223                // an agent that dies between them recovers from the state it was
3224                // in before this pass.
3225                if matches!(github.status, GithubRunnerObservation::Registered { .. }) {
3226                    self.deregister_runner(policy, &attempt).await;
3227                }
3228                let now = self.ports.clock.now();
3229                match (walk_to_busy, &outcome) {
3230                    (Some(runner_id), _) => {
3231                        self.transition(&mut attempt, |a| a.assigned_job(runner_id, now))?;
3232                    }
3233                    (None, AttemptOutcome::ExitedIdleWithoutWork) => {
3234                        self.transition(&mut attempt, |a| a.assignment_not_taken(now))?;
3235                    }
3236                    (None, _) => {}
3237                }
3238                self.conclude(&mut attempt, outcome)?;
3239                self.clean_or_quarantine(&mut attempt)?;
3240                return Ok(ReconcileProgress::Reconciled);
3241            }
3242        }
3243
3244        // A live runner journalled `busy` that GitHub now shows waiting, and
3245        // whose diagnostics show no job: the assignment was never taken up.
3246        // Back to `idle`, where the idle timeout applies to it again; the
3247        // `busy` arm below would otherwise adopt it for as long as it runs.
3248        if process_alive
3249            && attempt.state() == AttemptState::Busy
3250            && github.status == (GithubRunnerObservation::Registered { busy: false })
3251            && JobEvidence::of(attempt.runtime_path()) == JobEvidence::NoJob
3252        {
3253            let now = self.ports.clock.now();
3254            self.transition(&mut attempt, |a| a.assignment_not_taken(now))?;
3255            return Ok(ReconcileProgress::Reconciled);
3256        }
3257
3258        // This durable mark is more authoritative than a later observation
3259        // which cannot distinguish an agent kill from a crash.
3260        if self.ports.processes.has_terminate_intent(&attempt) && !process_alive {
3261            self.deregister_runner(policy, &attempt).await;
3262            self.conclude(
3263                &mut attempt,
3264                AttemptOutcome::failed(FailureReason::TerminatedAfterRegistrationTimeout),
3265            )?;
3266            self.clean_or_quarantine(&mut attempt)?;
3267            return Ok(ReconcileProgress::Replacement {
3268                attempt: attempt.id,
3269                operation: "registration_timeout_replacement",
3270            });
3271        }
3272
3273        // A remote registration with no surviving process has lost its
3274        // one-shot JIT secret.  Walking it to `starting` would require inventing
3275        // a PID; retrying the same registration would require inventing the
3276        // secret.  Record the configuration as expired and let the bounded
3277        // replacement path request a fresh one only if demand remains.
3278        if matches!(
3279            attempt.state(),
3280            AttemptState::Allocated | AttemptState::JitReceived
3281        ) && !process_alive
3282            && matches!(github.status, GithubRunnerObservation::Registered { .. })
3283        {
3284            if attempt.state() == AttemptState::Allocated {
3285                attempt
3286                    .jit_received(self.ports.clock.now())
3287                    .map_err(|_| LifecycleError::Transition)?;
3288                self.record(&attempt)?;
3289            }
3290            self.deregister_runner(policy, &attempt).await;
3291            self.conclude(
3292                &mut attempt,
3293                AttemptOutcome::failed(FailureReason::JitExpired),
3294            )?;
3295            self.clean_or_quarantine(&mut attempt)?;
3296            return Ok(ReconcileProgress::Replacement {
3297                attempt: attempt.id,
3298                operation: "jit_expired_replacement",
3299            });
3300        }
3301
3302        match recovery_decision(
3303            &attempt,
3304            RecoveryObservation {
3305                process_alive,
3306                github: github.status,
3307            },
3308            self.timeouts,
3309            self.ports.clock.as_ref(),
3310        ) {
3311            RecoveryDecision::Nothing | RecoveryDecision::Wait => Ok(ReconcileProgress::Reconciled),
3312            RecoveryDecision::Defer => Ok(ReconcileProgress::Deferred),
3313            RecoveryDecision::Adopt => {
3314                self.ports.events.emit(AttemptEvent::Adopted {
3315                    attempt: attempt.id,
3316                });
3317                Ok(ReconcileProgress::Reconciled)
3318            }
3319            RecoveryDecision::Clean => {
3320                self.clean_or_quarantine(&mut attempt)?;
3321                Ok(ReconcileProgress::Reconciled)
3322            }
3323            RecoveryDecision::Observe(state) => {
3324                let runner_id = known_runner_id(&attempt, github.runner_id)
3325                    .ok_or(LifecycleError::Transition)?;
3326                match state {
3327                    AttemptState::JitReceived => attempt
3328                        .jit_received(self.ports.clock.now())
3329                        .map_err(|_| LifecycleError::Transition)?,
3330                    AttemptState::Starting => {
3331                        let pid = attempt.process_id().ok_or(LifecycleError::Transition)?;
3332                        attempt
3333                            .started(pid, self.ports.clock.now())
3334                            .map_err(|_| LifecycleError::Transition)?;
3335                    }
3336                    AttemptState::Idle => attempt
3337                        .registered_idle(runner_id, self.ports.clock.now())
3338                        .map_err(|_| LifecycleError::Transition)?,
3339                    AttemptState::Busy => attempt
3340                        .assigned_job(runner_id, self.ports.clock.now())
3341                        .map_err(|_| LifecycleError::Transition)?,
3342                    _ => return Err(LifecycleError::Transition),
3343                }
3344                self.record(&attempt)?;
3345                Ok(ReconcileProgress::Reconciled)
3346            }
3347            RecoveryDecision::Conclude(outcome) => {
3348                let replacement = replacement_operation(&outcome);
3349                // Only when GitHub still holds one. Every other conclusion here
3350                // was reached *because* the observation was `NotRegistered`, and
3351                // spending a DELETE to be told so again would put a request per
3352                // concluded attempt on a budget `rest.rs` prices to the request.
3353                if matches!(github.status, GithubRunnerObservation::Registered { .. }) {
3354                    self.deregister_runner(policy, &attempt).await;
3355                }
3356                self.conclude(&mut attempt, outcome)?;
3357                self.clean_or_quarantine(&mut attempt)?;
3358                Ok(
3359                    replacement.map_or(ReconcileProgress::Reconciled, |operation| {
3360                        ReconcileProgress::Replacement {
3361                            attempt: attempt.id,
3362                            operation,
3363                        }
3364                    }),
3365                )
3366            }
3367            RecoveryDecision::Terminate(payload) => {
3368                // The mark is synced first, and what proves the process died is
3369                // the `is_alive` re-read below -- not the outcome recorded after
3370                // it. Which outcome that is depends on why the termination was
3371                // ordered, and only the payload knows: a `starting` runner that
3372                // never registered is a failure this agent then stopped, while
3373                // an `idle` one past its timeout is flow 2.7's surplus exit and
3374                // no failure at all. Hardcoding the first reason here labelled
3375                // the second as a registration timeout and asked the allocator
3376                // for a replacement to boot.
3377                let idle_exit = payload.is_idle_exit();
3378                self.ports
3379                    .processes
3380                    .record_terminate_intent(&attempt)
3381                    .map_err(LifecycleError::Failed)?;
3382                self.ports.events.emit(AttemptEvent::TerminateIntent {
3383                    attempt: attempt.id,
3384                });
3385                if self
3386                    .ports
3387                    .processes
3388                    .stop(&attempt)
3389                    .map_err(LifecycleError::Failed)?
3390                    == ProviderStop::StillRunning
3391                {
3392                    return Ok(ReconcileProgress::Deferred);
3393                }
3394                if self
3395                    .ports
3396                    .processes
3397                    .inspect(&attempt)
3398                    .map_err(LifecycleError::Failed)?
3399                    == EnvironmentState::Running
3400                {
3401                    return Ok(ReconcileProgress::Deferred);
3402                }
3403                self.ports.events.emit(AttemptEvent::Terminated {
3404                    attempt: attempt.id,
3405                });
3406                // The registration-timeout path keeps deriving its own reason
3407                // rather than applying the payload: on the pass that reads the
3408                // journalled mark back the process is dead, and
3409                // `TerminatedAfterRegistrationTimeout` is the reason that stays
3410                // true of a dead process. See `RecoveryDecision::Terminate`.
3411                let outcome = if idle_exit {
3412                    AttemptOutcome::ExitedIdleWithoutWork
3413                } else {
3414                    AttemptOutcome::failed(FailureReason::TerminatedAfterRegistrationTimeout)
3415                };
3416                self.deregister_runner(policy, &attempt).await;
3417                self.conclude(&mut attempt, outcome)?;
3418                self.clean_or_quarantine(&mut attempt)?;
3419                // A surplus runner is not replaced. It was stopped precisely
3420                // because the work it was started for went elsewhere; asking the
3421                // allocator for another one rebuilds it every idle timeout.
3422                if idle_exit {
3423                    Ok(ReconcileProgress::Reconciled)
3424                } else {
3425                    Ok(ReconcileProgress::Replacement {
3426                        attempt: attempt.id,
3427                        operation: "registration_timeout_replacement",
3428                    })
3429                }
3430            }
3431        }
3432    }
3433
3434    async fn reconcile_isolated_one(
3435        &self,
3436        policy: &ScalePolicy,
3437        mut attempt: RunnerAttempt,
3438    ) -> Result<ReconcileProgress, LifecycleError> {
3439        if attempt.state() == AttemptState::Cleaned {
3440            return Ok(ReconcileProgress::Reconciled);
3441        }
3442        if attempt.is_terminal() {
3443            self.clean_or_quarantine(&mut attempt)?;
3444            return Ok(ReconcileProgress::Reconciled);
3445        }
3446        if matches!(
3447            attempt.state(),
3448            AttemptState::Allocated | AttemptState::Preparing | AttemptState::Prepared
3449        ) {
3450            // No JIT was requested yet. Restart can safely retire this attempt;
3451            // a matching owned resource is stopped by the durable cleanup path.
3452            self.conclude(
3453                &mut attempt,
3454                AttemptOutcome::failed(FailureReason::Other(
3455                    "isolated preparation interrupted".into(),
3456                )),
3457            )?;
3458            self.clean_or_quarantine(&mut attempt)?;
3459            return Ok(ReconcileProgress::Reconciled);
3460        }
3461        let Ok(provider_state) = self.ports.processes.recover(&attempt) else {
3462            return Ok(ReconcileProgress::Deferred);
3463        };
3464        if provider_state != EnvironmentState::Missing {
3465            let Ok(owned) = self.ports.processes.owns(&attempt) else {
3466                return Ok(ReconcileProgress::Deferred);
3467            };
3468            if !owned {
3469                self.ports
3470                    .events
3471                    .emit(AttemptEvent::QuarantinedOwnedResource {
3472                        attempt: attempt.id,
3473                    });
3474                tracing::warn!(attempt_id = %attempt.id, reason = "isolated_resource_ownership_mismatch", "provider resource quarantined for operator review");
3475                return Ok(ReconcileProgress::Deferred);
3476            }
3477        }
3478        let live = matches!(
3479            provider_state,
3480            EnvironmentState::Starting | EnvironmentState::Running
3481        );
3482        if attempt.state() == AttemptState::JitReceived && live {
3483            attempt
3484                .started_isolated(self.ports.clock.now())
3485                .map_err(|_| LifecycleError::Transition)?;
3486            self.record(&attempt)?;
3487        }
3488        let github = self
3489            .ports
3490            .github
3491            .observe(&policy.target, attempt.id, &self.cancel)
3492            .await;
3493        if let Some(runner_id) = github.runner_id
3494            && read_runner_id(attempt.runtime_path()).is_none()
3495        {
3496            write_runner_id(attempt.runtime_path(), runner_id)?;
3497        }
3498        // A successfully reaped one-shot listener is stronger evidence than a
3499        // missing registration. Recovered guests have no attached child and
3500        // still follow the conservative orphan path below.
3501        if attempt.state() == AttemptState::Busy
3502            && !live
3503            && github.status == GithubRunnerObservation::NotRegistered
3504            && self.ports.processes.completed_successfully(&attempt)
3505        {
3506            self.conclude(&mut attempt, AttemptOutcome::CompletedJob)?;
3507            self.clean_or_quarantine(&mut attempt)?;
3508            return Ok(ReconcileProgress::Reconciled);
3509        }
3510        match recovery_decision(
3511            &attempt,
3512            RecoveryObservation {
3513                process_alive: live,
3514                github: github.status,
3515            },
3516            self.timeouts,
3517            self.ports.clock.as_ref(),
3518        ) {
3519            RecoveryDecision::Nothing | RecoveryDecision::Wait => Ok(ReconcileProgress::Reconciled),
3520            RecoveryDecision::Defer => Ok(ReconcileProgress::Deferred),
3521            RecoveryDecision::Adopt => {
3522                self.ports.events.emit(AttemptEvent::Adopted {
3523                    attempt: attempt.id,
3524                });
3525                Ok(ReconcileProgress::Reconciled)
3526            }
3527            RecoveryDecision::Clean => {
3528                self.clean_or_quarantine(&mut attempt)?;
3529                Ok(ReconcileProgress::Reconciled)
3530            }
3531            RecoveryDecision::Observe(state) => {
3532                let runner_id = github
3533                    .runner_id
3534                    .or_else(|| read_runner_id(attempt.runtime_path()))
3535                    .ok_or(LifecycleError::Transition)?;
3536                match state {
3537                    AttemptState::Idle => attempt
3538                        .registered_idle(runner_id, self.ports.clock.now())
3539                        .map_err(|_| LifecycleError::Transition)?,
3540                    AttemptState::Busy => attempt
3541                        .assigned_job(runner_id, self.ports.clock.now())
3542                        .map_err(|_| LifecycleError::Transition)?,
3543                    _ => return Err(LifecycleError::Transition),
3544                }
3545                self.record(&attempt)?;
3546                Ok(ReconcileProgress::Reconciled)
3547            }
3548            RecoveryDecision::Conclude(outcome) | RecoveryDecision::Terminate(outcome) => {
3549                let replacement = replacement_operation(&outcome);
3550                if matches!(github.status, GithubRunnerObservation::Registered { .. }) {
3551                    self.deregister_runner(policy, &attempt).await;
3552                }
3553                self.conclude(&mut attempt, outcome)?;
3554                self.clean_or_quarantine(&mut attempt)?;
3555                Ok(
3556                    replacement.map_or(ReconcileProgress::Reconciled, |operation| {
3557                        ReconcileProgress::Replacement {
3558                            attempt: attempt.id,
3559                            operation,
3560                        }
3561                    }),
3562                )
3563            }
3564        }
3565    }
3566
3567    /// Takes one domain edge and journals it.
3568    fn transition(
3569        &self,
3570        attempt: &mut RunnerAttempt,
3571        edge: impl FnOnce(
3572            &mut RunnerAttempt,
3573        ) -> Result<(), runner_manager_domain::attempt::AttemptError>,
3574    ) -> Result<(), LifecycleError> {
3575        edge(attempt).map_err(|_| LifecycleError::Transition)?;
3576        self.record(attempt)
3577    }
3578
3579    fn record(&self, attempt: &RunnerAttempt) -> Result<(), LifecycleError> {
3580        self.ports
3581            .store
3582            .record_attempt(attempt)
3583            .map_err(|_| LifecycleError::Journal)?;
3584        self.ports.events.emit(AttemptEvent::State {
3585            attempt: attempt.id,
3586            state: attempt.state(),
3587        });
3588        Ok(())
3589    }
3590
3591    /// Remove the GitHub registration an attempt is about to leave behind.
3592    ///
3593    /// # Why this is not fallible, and does not block the conclusion
3594    ///
3595    /// GitHub retires an ephemeral runner itself once that runner *completes a
3596    /// job*, and for the ordinary path that is the whole story. The paths that
3597    /// reach here are the ones where it does not: a runner stopped before it
3598    /// was ever assigned work, a registration whose process died still holding
3599    /// it, a JIT configuration that expired. Nothing else deletes those, and
3600    /// before this existed nothing did — they accumulated in the target's
3601    /// runner settings, one row per attempt, for the life of the repository.
3602    ///
3603    /// It returns `()` rather than a `Result` because the alternative is worse
3604    /// in both directions. The attempt is over: its process is gone and its
3605    /// slot has to come back, so a failed delete may not abort the conclusion
3606    /// or the host leaks capacity every time GitHub is unreachable. And a
3607    /// registration that outlives this call is not lost — it is exactly the
3608    /// `Registered` + dead-process observation that
3609    /// [`AttemptOutcome::Orphaned`] already names, which a later pass can still
3610    /// see. So a failure is logged and stepped over, deliberately.
3611    async fn deregister_runner(&self, policy: &ScalePolicy, attempt: &RunnerAttempt) {
3612        let Some(runner_id) = attempt
3613            .github_runner_id()
3614            .or_else(|| read_runner_id(attempt.runtime_path()))
3615        else {
3616            return;
3617        };
3618        if self
3619            .ports
3620            .github
3621            .deregister(&policy.target, runner_id, &self.cancel)
3622            .await
3623        {
3624            self.ports.events.emit(AttemptEvent::Deregistered {
3625                attempt: attempt.id,
3626                runner_id,
3627            });
3628        } else {
3629            tracing::warn!(
3630                attempt = %attempt.id,
3631                runner_id,
3632                "the runner registration could not be removed from GitHub; it will show in the \
3633                 target's runner settings until GitHub retires it or a later pass removes it"
3634            );
3635        }
3636    }
3637
3638    fn conclude(
3639        &self,
3640        attempt: &mut RunnerAttempt,
3641        outcome: AttemptOutcome,
3642    ) -> Result<(), LifecycleError> {
3643        attempt
3644            .conclude(outcome.clone(), self.ports.clock.now())
3645            .map_err(|_| LifecycleError::Transition)?;
3646        self.record(attempt)?;
3647        self.ports.events.emit(AttemptEvent::Concluded {
3648            attempt: attempt.id,
3649            outcome: OutcomeKind::of(&outcome),
3650        });
3651        Ok(())
3652    }
3653
3654    /// Clean a concluded attempt, tolerating a quarantined persistent slot.
3655    ///
3656    /// The quarantine is reported and stepped over rather than raised, because
3657    /// raising it aborts the whole pass: on the startup path that leaves
3658    /// `recovery_complete` false and stops the host launching anything, which is
3659    /// the opposite of `04-security-recovery.md`'s "it does not count as active
3660    /// host capacity" and "recovery retries the same cleanup". The attempt keeps
3661    /// its state, so it keeps its slot lease and its directory, and the next
3662    /// pass — [`crate::reconcile::Reconciler`]'s terminal sweep on every poll,
3663    /// or the next startup — attempts exactly the same cleanup again.
3664    ///
3665    /// A persistent-slot quarantine and an ephemeral directory still held by a
3666    /// late process are tolerated. A journal failure or a package lease that
3667    /// cannot be released still propagates: those are not one workspace's
3668    /// problem.
3669    ///
3670    /// A tolerated failure is retried with per-attempt backoff: until it is
3671    /// due again the attempt is skipped silently rather than re-tried and
3672    /// re-reported on every pass.
3673    fn clean_or_quarantine(&self, attempt: &mut RunnerAttempt) -> Result<(), LifecycleError> {
3674        let now = self.ports.clock.now();
3675        if !self.cleanup_retries().is_due(attempt.id, now) {
3676            return Ok(());
3677        }
3678        let reason = match self.clean_attempt(attempt) {
3679            Err(LifecycleError::SlotQuarantined { class, .. }) => class,
3680            Err(LifecycleError::WorkspaceCleanupDeferred) => {
3681                failure_reason_kind(&FailureReason::WorkspaceCleanupDeferred)
3682            }
3683            Err(LifecycleError::EnvironmentCleanupDeferred) => {
3684                "isolated_environment_cleanup_deferred"
3685            }
3686            other => {
3687                if other.is_ok() {
3688                    self.cleanup_retries().succeeded(attempt.id);
3689                }
3690                return other;
3691            }
3692        };
3693        self.cleanup_retries().failed(attempt.id, now);
3694        self.ports
3695            .reconcile_events
3696            .emit(LifecycleEvent::AttemptCleanFailed {
3697                policy: attempt.policy_id,
3698                attempt: attempt.id,
3699                reason,
3700            });
3701        Ok(())
3702    }
3703
3704    fn cleanup_retries(&self) -> std::sync::MutexGuard<'_, CleanBackoff> {
3705        self.cleanup_retries
3706            .lock()
3707            .unwrap_or_else(std::sync::PoisonError::into_inner)
3708    }
3709
3710    fn clean_attempt(&self, attempt: &mut RunnerAttempt) -> Result<(), LifecycleError> {
3711        if !attempt.execution().is_native() {
3712            return self.clean_isolated_attempt(attempt);
3713        }
3714        match self
3715            .ports
3716            .processes
3717            .destroy(attempt)
3718            .map_err(LifecycleError::Failed)?
3719        {
3720            ProviderDestroy::Destroyed => {}
3721            ProviderDestroy::Deferred(reason) => {
3722                return Err(LifecycleError::Failed(FailureReason::Other(reason.into())));
3723            }
3724        }
3725        let outcome = attempt
3726            .outcome()
3727            .cloned()
3728            .ok_or(LifecycleError::Transition)?;
3729        self.preserve_diagnostics(attempt, &outcome)?;
3730        self.scrub_workspace(attempt)?;
3731        self.ports
3732            .packages
3733            .release(attempt.id)
3734            .map_err(LifecycleError::Failed)?;
3735        attempt
3736            .clean(self.ports.clock.now())
3737            .map_err(|_| LifecycleError::Transition)?;
3738        self.record(attempt)?;
3739        let kind = OutcomeKind::of(&outcome);
3740        self.ports.events.emit(AttemptEvent::Cleaned {
3741            attempt: attempt.id,
3742            outcome: kind,
3743        });
3744        self.ports
3745            .reconcile_events
3746            .emit(LifecycleEvent::AttemptCleaned {
3747                policy: attempt.policy_id,
3748                attempt: attempt.id,
3749                outcome: kind,
3750            });
3751        Ok(())
3752    }
3753
3754    fn clean_isolated_attempt(&self, attempt: &mut RunnerAttempt) -> Result<(), LifecycleError> {
3755        if attempt.state() != AttemptState::Destroying {
3756            attempt
3757                .begin_destroy(self.ports.clock.now())
3758                .map_err(|_| LifecycleError::Transition)?;
3759            self.record(attempt)?;
3760        }
3761        let provider = self.ports.processes.as_ref();
3762        let allocation_resources = provider.enumerate_owned(self.host_id);
3763        let matching_allocation = allocation_resources
3764            .iter()
3765            .filter(|identity| identity_same_allocation(self.host_id, attempt, identity))
3766            .collect::<Vec<_>>();
3767        if matching_allocation.len() > 1
3768            || matching_allocation
3769                .iter()
3770                .any(|identity| !identity_matches_intent(self.host_id, attempt, identity))
3771        {
3772            self.ports
3773                .events
3774                .emit(AttemptEvent::QuarantinedOwnedResource {
3775                    attempt: attempt.id,
3776                });
3777            tracing::warn!(attempt_id = %attempt.id, reason = "duplicate_or_mismatched_isolated_ownership", "provider resources quarantined for operator review");
3778            return Err(self.defer_environment_cleanup(attempt)?);
3779        }
3780        let has_identity = matches!(
3781            attempt.execution(),
3782            AttemptExecution::Isolated {
3783                environment_id: Some(_),
3784                ..
3785            }
3786        );
3787        if !has_identity
3788            && allocation_resources
3789                .iter()
3790                .any(|identity| identity_matches_intent(self.host_id, attempt, identity))
3791        {
3792            return Err(self.defer_environment_cleanup(attempt)?);
3793        }
3794        let current = if has_identity {
3795            provider.inspect(attempt)
3796        } else {
3797            // A prepare effect may have succeeded before its identity could be
3798            // journalled. Empty enumeration alone is not proof of absence.
3799            provider.recover(attempt)
3800        };
3801        let current = match current {
3802            Ok(state) => state,
3803            Err(_) => return Err(self.defer_environment_cleanup(attempt)?),
3804        };
3805        if !has_identity && current != EnvironmentState::Missing {
3806            return Err(self.defer_environment_cleanup(attempt)?);
3807        }
3808        if current != EnvironmentState::Missing {
3809            if !matches!(provider.owns(attempt), Ok(true)) {
3810                self.ports
3811                    .events
3812                    .emit(AttemptEvent::QuarantinedOwnedResource {
3813                        attempt: attempt.id,
3814                    });
3815                tracing::warn!(attempt_id = %attempt.id, reason = "isolated_resource_ownership_mismatch", "provider resource quarantined for operator review");
3816                return Err(self.defer_environment_cleanup(attempt)?);
3817            }
3818            if matches!(
3819                current,
3820                EnvironmentState::Starting | EnvironmentState::Running
3821            ) && !matches!(provider.stop(attempt), Ok(ProviderStop::Stopped))
3822            {
3823                return Err(self.defer_environment_cleanup(attempt)?);
3824            }
3825            if !matches!(provider.destroy(attempt), Ok(ProviderDestroy::Destroyed)) {
3826                return Err(self.defer_environment_cleanup(attempt)?);
3827            }
3828            if !matches!(provider.inspect(attempt), Ok(EnvironmentState::Missing)) {
3829                return Err(self.defer_environment_cleanup(attempt)?);
3830            }
3831        }
3832        if provider
3833            .enumerate_owned(self.host_id)
3834            .iter()
3835            .any(|identity| identity_same_allocation(self.host_id, attempt, identity))
3836        {
3837            return Err(self.defer_environment_cleanup(attempt)?);
3838        }
3839        let outcome = attempt
3840            .outcome()
3841            .cloned()
3842            .ok_or(LifecycleError::Transition)?;
3843        // Provider diagnostics are a closed vocabulary and bounded before they
3844        // can enter tracing, even if an adapter returns an unbounded vector.
3845        for diagnostic in provider.diagnostics(attempt).into_iter().take(32) {
3846            tracing::debug!(attempt_id = %attempt.id, provider_diagnostic = ?diagnostic, "provider lifecycle diagnostic");
3847        }
3848        self.preserve_diagnostics(attempt, &outcome)?;
3849        self.scrub_workspace(attempt)?;
3850        self.ports
3851            .packages
3852            .release(attempt.id)
3853            .map_err(LifecycleError::Failed)?;
3854        attempt
3855            .clean_isolated(self.ports.clock.now())
3856            .map_err(|_| LifecycleError::Transition)?;
3857        self.record(attempt)?;
3858        self.ports.events.emit(AttemptEvent::Cleaned {
3859            attempt: attempt.id,
3860            outcome: OutcomeKind::of(&outcome),
3861        });
3862        self.ports
3863            .reconcile_events
3864            .emit(LifecycleEvent::AttemptCleaned {
3865                policy: attempt.policy_id,
3866                attempt: attempt.id,
3867                outcome: OutcomeKind::of(&outcome),
3868            });
3869        Ok(())
3870    }
3871
3872    fn defer_environment_cleanup(
3873        &self,
3874        attempt: &mut RunnerAttempt,
3875    ) -> Result<LifecycleError, LifecycleError> {
3876        attempt
3877            .defer_cleanup(self.ports.clock.now())
3878            .map_err(|_| LifecycleError::Transition)?;
3879        self.record(attempt)?;
3880        Ok(LifecycleError::EnvironmentCleanupDeferred)
3881    }
3882
3883    /// Undo an attempt's placement by the algorithm its journalled workspace
3884    /// kind makes legal (`02-target-architecture.md`, "Cleanup and recovery").
3885    ///
3886    /// The dispatch is on the *journal*, never on what the directory looks like
3887    /// now. A slot whose `_work` was replaced by a junction is still scrubbed as
3888    /// a slot rather than removed whole, and a disposable directory that happens
3889    /// to contain a `_work` still goes whole rather than being spared: the
3890    /// workspace kind is immutable precisely so that the shape of a directory a
3891    /// workflow can write to cannot choose the algorithm applied to it.
3892    fn scrub_workspace(&self, attempt: &RunnerAttempt) -> Result<(), LifecycleError> {
3893        #[cfg(test)]
3894        {
3895            // The two contamination mutants `f1` drives the security gates with.
3896            // They sit in front of the dispatch rather than inside one arm so
3897            // that a skipped cleanup is equally observable in both modes.
3898            if matches!(
3899                std::env::var("RUNNER_MANAGER_TEST_MUTANT").as_deref(),
3900                Ok("skip_workspace_cleanup" | "reuse_job_workspace")
3901            ) {
3902                return Ok(());
3903            }
3904        }
3905        match attempt.workspace() {
3906            AttemptWorkspace::Ephemeral => match remove_runtime_tree(attempt.runtime_path()) {
3907                Ok(()) => Ok(()),
3908                Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
3909                Err(_) => Err(LifecycleError::WorkspaceCleanupDeferred),
3910            },
3911            AttemptWorkspace::PersistentSlot { slot } => self.scrub_persistent_slot(attempt, slot),
3912        }
3913    }
3914
3915    /// Retain exactly `_work` and prove everything else is gone.
3916    ///
3917    /// The seven ordered checks of `04-security-recovery.md`, "Safe path
3918    /// handling": the journalled root and slot, a surviving policy's agreement,
3919    /// lexical and canonical containment, a literal enumeration, the one-entry
3920    /// allowlist, and a verification pass before the caller releases the package
3921    /// lease and marks the attempt cleaned. Any of them may refuse, and a
3922    /// refusal removes nothing after it.
3923    ///
3924    /// A slot directory that is already gone is not a refusal. There is nothing
3925    /// to scrub and nothing to prove absent, which is the same tolerance the
3926    /// disposable arm has always had for a runtime that vanished under it.
3927    fn scrub_persistent_slot(
3928        &self,
3929        attempt: &RunnerAttempt,
3930        slot: NonZeroU16,
3931    ) -> Result<(), LifecycleError> {
3932        // The policy is read from the journal, never inferred from the
3933        // directory tree, and its absence is legal: `Reconciler` cleans every
3934        // concluded attempt whether or not its policy still exists, and this
3935        // one's root is already journalled on the attempt itself.
3936        let configured = self
3937            .ports
3938            .store
3939            .policy(attempt.policy_id)
3940            .map_err(|_| LifecycleError::Journal)?
3941            .and_then(|policy| match policy.workspace_policy() {
3942                WorkspacePolicy::Persistent { root } => Some(root.clone()),
3943                WorkspacePolicy::Ephemeral => None,
3944            });
3945        let runtime = attempt.runtime_path();
3946        self.quarantine_on_refusal(
3947            attempt,
3948            verify_journalled_slot(runtime, slot, configured.as_ref())
3949                .and_then(|()| slot_is_present(runtime))
3950                .and_then(|present| {
3951                    if present {
3952                        scrub_slot_entries(runtime).and_then(|()| verify_slot_scrubbed(runtime))
3953                    } else {
3954                        Ok(())
3955                    }
3956                }),
3957        )
3958    }
3959
3960    /// Turn a slot refusal into the error that keeps the attempt uncleaned.
3961    ///
3962    /// The warning is emitted here, at the one place a quarantine is minted, so
3963    /// that both routes out of cleanup carry it: the reconciler's terminal sweep,
3964    /// which receives the error through the launcher port, and
3965    /// [`Self::clean_or_quarantine`], which swallows it to keep the pass alive.
3966    /// Everything logged is either a path this product configured or a constant
3967    /// from this module — see [`SlotQuarantine`] for why that matters.
3968    fn quarantine_on_refusal(
3969        &self,
3970        attempt: &RunnerAttempt,
3971        outcome: Result<(), SlotQuarantine>,
3972    ) -> Result<(), LifecycleError> {
3973        let Err(quarantine) = outcome else {
3974            return Ok(());
3975        };
3976        let detail = quarantine.to_string();
3977        tracing::warn!(
3978            attempt = %attempt.id,
3979            policy = %attempt.policy_id,
3980            slot = attempt.workspace().slot_number(),
3981            refusal = quarantine.refusal.class(),
3982            "{detail}"
3983        );
3984        Err(LifecycleError::SlotQuarantined {
3985            class: quarantine.refusal.class(),
3986            detail,
3987        })
3988    }
3989
3990    fn preserve_diagnostics(
3991        &self,
3992        attempt: &RunnerAttempt,
3993        outcome: &AttemptOutcome,
3994    ) -> Result<(), LifecycleError> {
3995        fs::create_dir_all(&self.diagnostics_root).map_err(|_| {
3996            LifecycleError::Failed(FailureReason::Other(
3997                "diagnostics directory could not be created".into(),
3998            ))
3999        })?;
4000        // Intentionally constructed from typed local facts, not runner output.
4001        // Raw child output can contain workflow secrets and is never copied.
4002        let reason_kind = match outcome {
4003            AttemptOutcome::Failed { reason } => failure_reason_kind(reason),
4004            _ => "none",
4005        };
4006        let diagnostic = format!(
4007            "attempt_id={}\npolicy_id={}\noutcome={}\nreason={}\n",
4008            attempt.id,
4009            attempt.policy_id,
4010            OutcomeKind::of(outcome).as_str(),
4011            reason_kind,
4012        );
4013        fs::write(
4014            self.diagnostics_root.join(format!("{}.log", attempt.id)),
4015            diagnostic,
4016        )
4017        .map_err(|_| {
4018            LifecycleError::Failed(FailureReason::Other(
4019                "redacted diagnostics could not be preserved".into(),
4020            ))
4021        })
4022    }
4023
4024    async fn materialize_with_retry(
4025        &self,
4026        policy: &ScalePolicy,
4027        attempt: &RunnerAttempt,
4028    ) -> Result<RunnerVersion, FailureReason> {
4029        let mut issued = 0_u32;
4030        loop {
4031            issued = issued.saturating_add(1);
4032            match self.ports.packages.materialize(attempt).await {
4033                Ok(version) => return Ok(version),
4034                Err(reason)
4035                    if package_failure_is_terminal(&reason)
4036                        || issued >= self.retry.max_attempts.max(1) =>
4037                {
4038                    return Err(reason);
4039                }
4040                Err(reason) => {
4041                    if !self.ports.demand.persists(policy.id).await {
4042                        return Err(reason);
4043                    }
4044                    let delay = self.retry.delay(issued);
4045                    self.ports.events.emit(AttemptEvent::Retry {
4046                        attempt: attempt.id,
4047                        operation: "package_materialization",
4048                        delay,
4049                    });
4050                    self.ports.delay.wait(delay).await;
4051                    if !self.ports.demand.persists(policy.id).await {
4052                        return Err(reason);
4053                    }
4054                }
4055            }
4056        }
4057    }
4058
4059    async fn register_with_retry(
4060        &self,
4061        policy: &ScalePolicy,
4062        attempt: AttemptId,
4063        request: &JitRunnerRequest,
4064    ) -> Result<JitRegistration, LifecycleError> {
4065        let mut issued = 0_u32;
4066        loop {
4067            issued = issued.saturating_add(1);
4068            match self
4069                .ports
4070                .github
4071                .register(&policy.target, request, &self.cancel)
4072                .await
4073            {
4074                Ok(registration) => return Ok(registration),
4075                Err(error) if error.terminal => {
4076                    return Err(LifecycleError::Failed(error.reason));
4077                }
4078                Err(error) => {
4079                    if issued >= self.retry.max_attempts.max(1)
4080                        || !self.ports.demand.persists(policy.id).await
4081                    {
4082                        return Err(LifecycleError::Failed(error.reason));
4083                    }
4084                    let delay = error
4085                        .retry_after
4086                        .unwrap_or_else(|| self.retry.delay(issued));
4087                    self.ports.events.emit(AttemptEvent::Retry {
4088                        attempt,
4089                        operation: "jit_request",
4090                        delay,
4091                    });
4092                    self.ports.delay.wait(delay).await;
4093                    if !self.ports.demand.persists(policy.id).await {
4094                        return Err(LifecycleError::Failed(error.reason));
4095                    }
4096                }
4097            }
4098        }
4099    }
4100
4101    /// Where one attempt's files go, decided while the host allocation lock is
4102    /// held and before anything external happens.
4103    ///
4104    /// The branch is on the *repository's configured* workspace policy, so an
4105    /// organization policy and an ephemeral repository never reach slot
4106    /// selection at all: a persistent policy is unrepresentable for an
4107    /// organization target (D7, refused by `WorkspacePolicy::permitted_for` in
4108    /// both the constructor and the loader), and an ephemeral repository takes
4109    /// the disposable arm that existed before slots did.
4110    fn allocate_workspace(
4111        &self,
4112        policy: &ScalePolicy,
4113        id: AttemptId,
4114    ) -> Result<Placement, LifecycleError> {
4115        let placement = match policy.workspace_policy() {
4116            // Precedence (`02-target-architecture.md`): the repository's
4117            // persistent root is selected *before* the host root, which is why
4118            // this arm is first and why it never makes resolving the host
4119            // default a precondition of its own success.
4120            WorkspacePolicy::Persistent { root } => self.allocate_persistent_slot(policy, root),
4121            WorkspacePolicy::Ephemeral => self.allocate_disposable(policy, id),
4122        };
4123        // Here rather than inside the two arms, so that every path a root can
4124        // accept clears the record and none can be forgotten.
4125        if placement.is_ok() {
4126            self.root_accepted(policy.id);
4127        }
4128        placement
4129    }
4130
4131    /// `Host.runner_root_override`, read from the journal.
4132    ///
4133    /// Separated from [`Self::effective_host_root`] so that the two failures it
4134    /// folds together stay apart: an unreadable or missing host row is a journal
4135    /// problem and is always fatal, while an unresolvable *platform default* is
4136    /// only fatal to a placement that actually needs the host root.
4137    fn configured_host_root(&self) -> Result<Option<LocalAbsolutePath>, LifecycleError> {
4138        let host = self
4139            .ports
4140            .store
4141            .host(self.host_id)
4142            .map_err(|_| LifecycleError::Journal)?
4143            .ok_or_else(|| LifecycleError::Failed(FailureReason::Other("host not found".into())))?;
4144        Ok(host.runner_root_override.clone())
4145    }
4146
4147    /// `Host.runner_root_override`, or the platform default when that resolves;
4148    /// `None` when neither does. Unlike [`Self::effective_host_root`] it records
4149    /// no refusal, for callers to whom an unresolvable root is not a failure.
4150    fn resolvable_host_root(&self) -> Result<Option<LocalAbsolutePath>, LifecycleError> {
4151        Ok(self
4152            .configured_host_root()?
4153            .or_else(|| default_runner_root(&self.app_paths).ok()))
4154    }
4155
4156    /// `Host.runner_root_override`, or the platform default standing in for it.
4157    ///
4158    /// Takes the policy because an unresolvable default is a refusal like any
4159    /// other, and the record it leaves is that policy's.
4160    fn effective_host_root(
4161        &self,
4162        policy: &ScalePolicy,
4163    ) -> Result<LocalAbsolutePath, LifecycleError> {
4164        match self.configured_host_root()? {
4165            Some(configured) => Ok(configured),
4166            None => default_runner_root(&self.app_paths).map_err(|error| {
4167                // No root resolved, so there is no path to name but the one the
4168                // platform would have produced.
4169                self.root_refused(policy.id, "the platform default runner root", error)
4170            }),
4171        }
4172    }
4173
4174    /// Turns a runner-root refusal into a failure, and leaves the sentence
4175    /// somewhere an operator can read it.
4176    ///
4177    /// Per policy, because the policies on a host do not share a fate: one with
4178    /// its own persistent root places runners while another on a withheld
4179    /// volume places none, and a host-wide record would have the first clear
4180    /// the second's on the same pass.
4181    ///
4182    /// The recording is best-effort and its failure is deliberately swallowed:
4183    /// this runs on the path that is already failing, and a host that cannot
4184    /// write a diagnostic file must still report the refusal it came to report.
4185    /// `service status` says so itself when the file is unreadable.
4186    fn root_refused(&self, policy: PolicyId, root: &str, error: RunnerRootError) -> LifecycleError {
4187        let _ = runner_manager_platform::service::record_runner_root_refusal(
4188            &self.app_paths,
4189            &policy.to_string(),
4190            self.ports.clock.now(),
4191            error.kind(),
4192            root,
4193            &error.to_string(),
4194        );
4195        root_failure(error)
4196    }
4197
4198    /// Clears that policy's record, because its root accepted a placement.
4199    ///
4200    /// Called on every successful placement rather than only after a failure:
4201    /// the daemon that recovers is often not the process that failed -- a
4202    /// self-update restarts it -- so "clear it if we wrote it" would leave a
4203    /// stale note on `service status` for as long as the host ran.
4204    fn root_accepted(&self, policy: PolicyId) {
4205        let _ = runner_manager_platform::service::clear_runner_root_refusal(
4206            &self.app_paths,
4207            &policy.to_string(),
4208        );
4209    }
4210
4211    /// D3's disposable placement: a unique child of the effective host root,
4212    /// removed whole on cleanup. `c1`'s behaviour, moved behind the branch.
4213    fn allocate_disposable(
4214        &self,
4215        policy: &ScalePolicy,
4216        id: AttemptId,
4217    ) -> Result<Placement, LifecycleError> {
4218        let effective_root = self.effective_host_root(policy)?;
4219        RootPreflight::new(&self.app_paths)
4220            .check(&RootOwner::Host, &effective_root)
4221            .map_err(|error| self.root_refused(policy.id, effective_root.as_str(), error))?;
4222        let runtime = effective_root.as_path().join({
4223            #[cfg(test)]
4224            {
4225                if std::env::var("RUNNER_MANAGER_TEST_MUTANT").as_deref()
4226                    == Ok("reuse_job_workspace")
4227                {
4228                    "mutant-shared-workspace".to_owned()
4229                } else {
4230                    workspace_name(id)
4231                }
4232            }
4233            #[cfg(not(test))]
4234            {
4235                workspace_name(id)
4236            }
4237        });
4238        fs::create_dir_all(&runtime)
4239            .map_err(|_| LifecycleError::Failed(FailureReason::ProcessStartFailed))?;
4240        Ok(Placement {
4241            runtime,
4242            workspace: AttemptWorkspace::Ephemeral,
4243        })
4244    }
4245
4246    /// D4/D5's persistent placement: the lowest free `sN` under the repository's
4247    /// configured root.
4248    ///
4249    /// Steps 1 to 6 of `02-target-architecture.md`, "Slot allocation", in order;
4250    /// step 7 is the journal write [`Self::record_allocation`] owns. All of them
4251    /// run under the host allocation lock, because [`Self::launch_attempt`] is
4252    /// reachable only through a `LaunchRequest` and that carries the guard.
4253    ///
4254    /// **The filesystem is never consulted to decide which slots are taken**
4255    /// (invariant 6). The leases come from the journal; the directory is
4256    /// inspected only to decide whether *this* slot is safe to reuse.
4257    fn allocate_persistent_slot(
4258        &self,
4259        policy: &ScalePolicy,
4260        root: &LocalAbsolutePath,
4261    ) -> Result<Placement, LifecycleError> {
4262        // 1-4. The lowest positive slot no uncleaned attempt holds, refused
4263        // above the policy ceiling.
4264        let ceiling = policy.max_capacity().ok_or_else(|| {
4265            LifecycleError::Failed(FailureReason::Other(
4266                "a persistent workspace needs the policy's max_capacity to bound its slots"
4267                    .to_string(),
4268            ))
4269        })?;
4270        let leases = self
4271            .ports
4272            .store
4273            .slot_leases_for_policy(policy.id)
4274            .map_err(|_| LifecycleError::Journal)?;
4275        let slot = lowest_free_slot(&leases, ceiling).ok_or_else(|| {
4276            LifecycleError::Failed(FailureReason::Other(format!(
4277                "every persistent slot s1 to s{ceiling} for {} is leased by an attempt that has \
4278                 not been cleaned, so no slot is free; raise the repository's max capacity, or \
4279                 finish cleaning a concluded attempt",
4280                policy.target
4281            )))
4282        })?;
4283        let workspace = AttemptWorkspace::persistent_slot(slot);
4284        let name = workspace
4285            .slot_directory_name()
4286            .expect("a persistent allocation names its slot directory");
4287
4288        // The operational preflight, for the reasons the host root gets one: a
4289        // root that is remote, unwritable, or overlapping application data has
4290        // to fail before a directory is created rather than after. The host
4291        // root is registered only as something *not* to overlap; a host default
4292        // that cannot be resolved is a host-root problem and does not block a
4293        // repository that configured a root of its own.
4294        //
4295        // Only *that* failure is tolerated. An unreadable host row is a journal
4296        // failure and propagates, because silently continuing would drop the
4297        // overlap check entirely and accept a repository root that sits inside
4298        // the host root — the pair `RootPreflight` exists to refuse.
4299        let host_root = self.resolvable_host_root()?;
4300        let mut preflight = RootPreflight::new(&self.app_paths);
4301        if let Some(host_root) = host_root {
4302            preflight = preflight.against(RootOwner::Host, host_root);
4303        }
4304        let checked = preflight
4305            .check(&RootOwner::Repository(policy.target.to_string()), root)
4306            .map_err(|error| self.root_refused(policy.id, root.as_str(), error))?;
4307        if let Some(leaf) = checked.leaf_to_create() {
4308            fs::create_dir(leaf).map_err(|source| {
4309                LifecycleError::Failed(FailureReason::Other(format!(
4310                    "the persistent workspace root {} could not be created: {source}",
4311                    leaf.display()
4312                )))
4313            })?;
4314        }
4315
4316        // 5-6. `<root>/sN`, contained lexically by construction, then created or
4317        // validated, then contained canonically now that it resolves, and only
4318        // then accepted for reuse.
4319        let slot_path = runner_root::derive_child(root, &name)
4320            .map_err(|error| self.root_refused(policy.id, root.as_str(), error))?;
4321        create_or_validate_slot(slot_path.as_path())?;
4322        runner_root::verify_containment(root, &slot_path)
4323            .map_err(|error| self.root_refused(policy.id, root.as_str(), error))?;
4324        accept_reusable_slot(slot_path.as_path())?;
4325        Ok(Placement {
4326            runtime: slot_path.as_path().to_path_buf(),
4327            workspace,
4328        })
4329    }
4330
4331    /// The first journal write of an attempt, where a duplicate slot lease is
4332    /// still possible and has to be reported as itself.
4333    ///
4334    /// [`Self::record`] flattens every store failure into
4335    /// [`LifecycleError::Journal`], which is right for a state transition and
4336    /// wrong here: the partial unique index
4337    /// `one_uncleaned_persistent_attempt_per_slot` is the final race fence
4338    /// (`04-security-recovery.md`, "two attempts use one slot concurrently"),
4339    /// and an operator who reaches it needs to read that rather than "attempt
4340    /// journal operation failed". Nothing was written, so the caller returns
4341    /// without concluding an attempt that is not in the journal.
4342    fn record_allocation(&self, attempt: &RunnerAttempt) -> Result<(), LifecycleError> {
4343        match self.ports.store.record_attempt(attempt) {
4344            Ok(()) => {
4345                self.ports.events.emit(AttemptEvent::State {
4346                    attempt: attempt.id,
4347                    state: attempt.state(),
4348                });
4349                Ok(())
4350            }
4351            Err(error @ StoreError::SlotAlreadyLeased { .. }) => Err(LifecycleError::Failed(
4352                FailureReason::Other(error.to_string()),
4353            )),
4354            Err(_) => Err(LifecycleError::Journal),
4355        }
4356    }
4357
4358    async fn launch_attempt(
4359        &self,
4360        policy: &ScalePolicy,
4361        allocation_guard: &AllocationGuard,
4362        lock_wait: Duration,
4363    ) -> Result<RunnerAttempt, LifecycleError> {
4364        let (attempt, labels) = self.allocate_attempt(policy)?;
4365        let id = attempt.id;
4366        let mut stopwatch = LaunchStopwatch::new(lock_wait);
4367        let result = self
4368            .launch_steps(policy, allocation_guard, attempt, labels, &mut stopwatch)
4369            .await;
4370        stopwatch.finish();
4371        self.ports.events.emit(AttemptEvent::Launched {
4372            attempt: id,
4373            started: result.is_ok(),
4374            timings: stopwatch.timings,
4375        });
4376        result
4377    }
4378
4379    /// Place and journal a new attempt: everything before its first external
4380    /// effect, and so before there is anything to time.
4381    fn allocate_attempt<'p>(
4382        &self,
4383        policy: &'p ScalePolicy,
4384    ) -> Result<(RunnerAttempt, &'p RoutingLabels), LifecycleError> {
4385        if !*self
4386            .recovery_complete
4387            .lock()
4388            .map_err(|_| LifecycleError::Journal)?
4389        {
4390            return Err(LifecycleError::RecoveryIncomplete);
4391        }
4392        let capability = self.ports.processes.probe(policy);
4393        if capability != ProviderCapability::Ready {
4394            return Err(LifecycleError::Failed(capability.refusal()));
4395        }
4396        let resolved = self.ports.processes.resolve(policy).map_err(|reason| {
4397            LifecycleError::Failed(if policy.execution_policy().is_native() {
4398                reason
4399            } else {
4400                safe_isolation_reason(reason, IsolationProviderFailure::RuntimeOperationFailed)
4401            })
4402        })?;
4403        if policy.execution_policy().is_native() != resolved.is_none() {
4404            return Err(LifecycleError::Failed(FailureReason::Other(
4405                "provider resolution mismatch".into(),
4406            )));
4407        }
4408        if let Some(resolution) = &resolved {
4409            if resolution.provider_kind == Backend::Auto {
4410                return Err(LifecycleError::Failed(FailureReason::Other(
4411                    "provider resolution mismatch".into(),
4412                )));
4413            }
4414            AttemptExecution::Isolated {
4415                provider_kind: resolution.provider_kind,
4416                environment_id: None,
4417                resolved_image: resolution.image.clone(),
4418                generation: "generation-check".into(),
4419            }
4420            .validate()
4421            .map_err(|_| {
4422                LifecycleError::Failed(FailureReason::Other(
4423                    "resolved image is incompatible".into(),
4424                ))
4425            })?;
4426        }
4427        let labels = policy
4428            .routing_labels()
4429            .ok_or(LifecycleError::Failed(FailureReason::JitRequestFailed))?;
4430        let id = AttemptId::new_random();
4431        let placement = self.allocate_workspace(policy, id)?;
4432        let mut attempt = RunnerAttempt::allocate_in(
4433            id,
4434            policy.id,
4435            placement.runtime,
4436            placement.workspace,
4437            self.ports.clock.now(),
4438        );
4439        if let Some(resolution) = &resolved {
4440            attempt
4441                .allocate_execution(AttemptExecution::Isolated {
4442                    provider_kind: resolution.provider_kind,
4443                    environment_id: None,
4444                    resolved_image: resolution.image.clone(),
4445                    generation: uuid::Uuid::new_v4().to_string(),
4446                })
4447                .map_err(|_| LifecycleError::Transition)?;
4448        }
4449        // This is deliberately the first effect after directory allocation, and
4450        // for a persistent attempt it is also what makes the slot lease durable
4451        // before any package or GitHub effect
4452        // (`02-target-architecture.md`, "Slot allocation", step 7).
4453        self.record_allocation(&attempt)?;
4454        Ok((attempt, labels))
4455    }
4456
4457    async fn launch_steps(
4458        &self,
4459        policy: &ScalePolicy,
4460        allocation_guard: &AllocationGuard,
4461        mut attempt: RunnerAttempt,
4462        labels: &RoutingLabels,
4463        stopwatch: &mut LaunchStopwatch,
4464    ) -> Result<RunnerAttempt, LifecycleError> {
4465        let id = attempt.id;
4466        let isolated = !attempt.execution().is_native();
4467        stopwatch.begin(LaunchStep::Materialize);
4468        let version = match self.materialize_with_retry(policy, &attempt).await {
4469            Ok(version) => version,
4470            Err(reason) => return self.fail_launch(&mut attempt, reason),
4471        };
4472        self.prune_under_allocation_lock(allocation_guard, &version)?;
4473        self.versions
4474            .lock()
4475            .map_err(|_| LifecycleError::Journal)?
4476            .insert(id, version);
4477
4478        if isolated {
4479            attempt
4480                .begin_prepare(self.ports.clock.now())
4481                .map_err(|_| LifecycleError::Transition)?;
4482            self.record(&attempt)?;
4483        }
4484        let prepared = match self.ports.processes.prepare(&attempt, policy) {
4485            Ok(prepared) => prepared,
4486            Err(reason) => {
4487                let safe_reason = if isolated {
4488                    safe_isolation_reason(reason, IsolationProviderFailure::RuntimeOperationFailed)
4489                } else {
4490                    reason
4491                };
4492                return self.fail_launch(&mut attempt, safe_reason);
4493            }
4494        };
4495        if isolated {
4496            let Some(identity) = prepared.identity() else {
4497                return Err(LifecycleError::Failed(FailureReason::IsolationProvider(
4498                    IsolationProviderFailure::OwnershipMismatch,
4499                )));
4500            };
4501            if !identity_matches_intent(self.host_id, &attempt, identity) {
4502                return Err(LifecycleError::Failed(FailureReason::IsolationProvider(
4503                    IsolationProviderFailure::OwnershipMismatch,
4504                )));
4505            }
4506            let EnvironmentIdentity::Isolated { environment_id, .. } = identity else {
4507                unreachable!()
4508            };
4509            attempt
4510                .prepared_environment(environment_id.clone())
4511                .map_err(|_| LifecycleError::Transition)?;
4512            attempt
4513                .mark_prepared(self.ports.clock.now())
4514                .map_err(|_| LifecycleError::Transition)?;
4515            self.record(&attempt)?;
4516        }
4517
4518        stopwatch.begin(LaunchStep::Register);
4519        let jit_request =
4520            JitRunnerRequest::for_policy(runner_name(id), self.runner_group_id, labels);
4521        let registration = match self.register_with_retry(policy, id, &jit_request).await {
4522            Ok(registration) => registration,
4523            Err(error) => return self.fail_launch(&mut attempt, error.reason()),
4524        };
4525        stopwatch.begin(LaunchStep::Spawn);
4526        let runner_id = registration.runner().id;
4527        write_runner_id(attempt.runtime_path(), runner_id)?;
4528        attempt
4529            .jit_received(self.ports.clock.now())
4530            .map_err(|_| LifecycleError::Transition)?;
4531        self.record(&attempt)?;
4532        let config = registration.into_config();
4533        if isolated {
4534            let started = self
4535                .ports
4536                .processes
4537                .start(prepared, &attempt, OneTimeJitHandoff::new(&config))
4538                .map_err(|failure| {
4539                    LifecycleError::Failed(safe_isolation_reason(
4540                        failure.reason,
4541                        IsolationProviderFailure::RuntimeOperationFailed,
4542                    ))
4543                })?;
4544            if !identity_matches_intent(self.host_id, &attempt, &started) {
4545                return Err(LifecycleError::Failed(FailureReason::IsolationProvider(
4546                    IsolationProviderFailure::OwnershipMismatch,
4547                )));
4548            }
4549            attempt
4550                .started_isolated(self.ports.clock.now())
4551                .map_err(|_| LifecycleError::Transition)?;
4552            self.record(&attempt)?;
4553            return Ok(attempt);
4554        }
4555        let mut issued = 0_u32;
4556        let pid = loop {
4557            issued = issued.saturating_add(1);
4558            match self.ports.processes.start(
4559                prepared.clone(),
4560                &attempt,
4561                OneTimeJitHandoff::new(&config),
4562            ) {
4563                Ok(EnvironmentIdentity::NativeProcess(pid)) => break pid,
4564                Ok(EnvironmentIdentity::Isolated { .. }) => {
4565                    // No isolated launch sequence is wired in this task. Keep
4566                    // the nonterminal JIT journal as a capacity fence rather
4567                    // than treating an unexpected resource as a native PID.
4568                    return Err(LifecycleError::Failed(FailureReason::Other(
4569                        "unexpected isolated environment identity".into(),
4570                    )));
4571                }
4572                Err(error) => {
4573                    if let Some(pid) = error.live_pid {
4574                        attempt
4575                            .started(pid, self.ports.clock.now())
4576                            .map_err(|_| LifecycleError::Transition)?;
4577                        self.record(&attempt)?;
4578                        return Err(LifecycleError::Failed(error.reason));
4579                    }
4580                    if !error.retryable
4581                        || issued >= self.retry.max_attempts.max(1)
4582                        || !self.ports.demand.persists(policy.id).await
4583                    {
4584                        return self.fail_launch(&mut attempt, error.reason);
4585                    }
4586                    let delay = self.retry.delay(issued);
4587                    self.ports.events.emit(AttemptEvent::Retry {
4588                        attempt: attempt.id,
4589                        operation: "process_start",
4590                        delay,
4591                    });
4592                    self.ports.delay.wait(delay).await;
4593                    if !self.ports.demand.persists(policy.id).await {
4594                        return self.fail_launch(&mut attempt, error.reason);
4595                    }
4596                }
4597            }
4598        };
4599        attempt
4600            .started(pid, self.ports.clock.now())
4601            .map_err(|_| LifecycleError::Transition)?;
4602        self.record(&attempt)?;
4603        Ok(attempt)
4604    }
4605
4606    fn fail_launch<T>(
4607        &self,
4608        attempt: &mut RunnerAttempt,
4609        reason: FailureReason,
4610    ) -> Result<T, LifecycleError> {
4611        self.conclude(attempt, AttemptOutcome::failed(reason.clone()))?;
4612        Err(LifecycleError::Failed(reason))
4613    }
4614
4615    /// e2's prune guard is invoked only with e1's allocation guard borrowed.
4616    /// The otherwise-unused argument is a compile-time witness of the ordering.
4617    fn prune_under_allocation_lock(
4618        &self,
4619        guard: &AllocationGuard,
4620        version: &RunnerVersion,
4621    ) -> Result<(), LifecycleError> {
4622        let attempts = self
4623            .ports
4624            .store
4625            .attempts()
4626            .map_err(|_| LifecycleError::Journal)?;
4627        self.ports
4628            .packages
4629            .prune_obsolete_guarded(
4630                PruneAuthority::from_launch_request(guard),
4631                version,
4632                &attempts,
4633            )
4634            .map_err(LifecycleError::Failed)?;
4635        #[cfg(target_os = "macos")]
4636        self.prune_abandoned_seeds(&attempts);
4637        Ok(())
4638    }
4639
4640    /// Remove the package seed from every runner root this host once placed a
4641    /// runtime in and no longer does: a changed host root override, a removed
4642    /// persistent policy, or one that moved its workspace.
4643    ///
4644    /// The roots ever used are the parents of the journal's runtime paths; the
4645    /// roots in use are the effective host root and every persistent policy's
4646    /// root. When the effective host root cannot be resolved nothing is
4647    /// removed, because a seed still in use cannot then be told apart. Runs
4648    /// under the allocation lock, so no launch is cloning from a seed it
4649    /// removes, and it is best-effort: a seed left behind costs disk, never a
4650    /// launch.
4651    #[cfg(target_os = "macos")]
4652    fn prune_abandoned_seeds(&self, attempts: &[RunnerAttempt]) {
4653        let Ok(policies) = self.ports.store.policies() else {
4654            return;
4655        };
4656        let Ok(Some(host_root)) = self.resolvable_host_root() else {
4657            return;
4658        };
4659        let in_use: BTreeSet<PathBuf> = policies
4660            .iter()
4661            .filter_map(|policy| match policy.workspace_policy() {
4662                WorkspacePolicy::Persistent { root } => Some(canonical_or_raw(root.as_path())),
4663                WorkspacePolicy::Ephemeral => None,
4664            })
4665            .chain(std::iter::once(canonical_or_raw(host_root.as_path())))
4666            .collect();
4667        // Distinct parents first: the journal keeps every attempt ever made.
4668        let used: BTreeSet<&Path> = attempts
4669            .iter()
4670            .filter_map(|attempt| attempt.runtime_path().parent())
4671            .collect();
4672        for root in used.into_iter().map(canonical_or_raw) {
4673            if !in_use.contains(&root) {
4674                let _ = remove_runtime_tree(&root.join(PACKAGE_SEED_DIR));
4675            }
4676        }
4677    }
4678}
4679
4680#[cfg(target_os = "macos")]
4681fn canonical_or_raw(path: &Path) -> PathBuf {
4682    fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
4683}
4684
4685#[async_trait]
4686impl RunnerLauncher for LifecycleLauncher {
4687    async fn supervise(
4688        &self,
4689        policy: &ScalePolicy,
4690    ) -> Result<Vec<ReplacementIntent>, LaunchFailure> {
4691        LifecycleLauncher::supervise(self, policy)
4692            .await
4693            .map_err(|error| LaunchFailure::new(error.reason()))
4694    }
4695
4696    async fn attempts(&self) -> Result<Vec<RunnerAttempt>, LaunchFailure> {
4697        self.ports.store.attempts().map_err(|_| {
4698            LaunchFailure::new(FailureReason::Other(
4699                "attempt journal could not be read".into(),
4700            ))
4701        })
4702    }
4703
4704    async fn launch(&self, request: LaunchRequest<'_>) -> Result<RunnerAttempt, LaunchFailure> {
4705        self.launch_attempt(request.policy, request.allocation_guard, request.lock_wait)
4706            .await
4707            .map_err(|error| LaunchFailure::new(error.reason()))
4708    }
4709
4710    async fn clean(&self, id: AttemptId) -> Result<(), LaunchFailure> {
4711        let mut attempt = self
4712            .ports
4713            .store
4714            .attempt(id)
4715            .map_err(|_| {
4716                LaunchFailure::new(FailureReason::Other(
4717                    "attempt journal could not be read".into(),
4718                ))
4719            })?
4720            .ok_or_else(|| {
4721                LaunchFailure::new(FailureReason::Other(
4722                    "attempt disappeared from the journal".into(),
4723                ))
4724            })?;
4725        self.clean_attempt(&mut attempt)
4726            .map_err(|error| LaunchFailure::new(error.reason()))?;
4727        self.cleanup_retries().succeeded(id);
4728        Ok(())
4729    }
4730}
4731
4732fn runner_name(attempt: AttemptId) -> String {
4733    format!("runner-manager-{attempt}")
4734}
4735
4736/// How long reading a runner's diagnostics may take.
4737const EVIDENCE_DEADLINE: Duration = Duration::from_secs(2);
4738
4739/// What a native runner's own diagnostics say about whether it ran a job.
4740///
4741/// `Runner.Listener` writes `_diag/Runner_*.log` when it starts, and starts
4742/// `Runner.Worker` -- which writes `_diag/Worker_*.log` -- only for a job it has
4743/// acquired. So a worker log is proof a job ran, and a listener log without one
4744/// is proof none did. With neither, the directory is not where the listener
4745/// wrote, and nothing is concluded from it. `_diag` is scrubbed with the rest of
4746/// the runtime after every attempt, persistent slots included, so a log found
4747/// here belongs to this attempt.
4748#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4749enum JobEvidence {
4750    RanAJob,
4751    NoJob,
4752    Unknown,
4753}
4754
4755impl JobEvidence {
4756    fn of(runtime: &Path) -> Self {
4757        let diag = runtime.join("_diag");
4758        // Bounded: the daemon's loop runs on one thread, and a runtime on a
4759        // volume that does not answer (a hung disk, a pending macOS question)
4760        // must not stall it for longer than this.
4761        if runner_manager_platform::host_fitness::directory_responds(&diag, EVIDENCE_DEADLINE)
4762            != runner_manager_platform::host_fitness::Responsiveness::Responds
4763        {
4764            return Self::Unknown;
4765        }
4766        let Ok(entries) = fs::read_dir(diag) else {
4767            return Self::Unknown;
4768        };
4769        let mut listener = false;
4770        for entry in entries.flatten() {
4771            let name = entry.file_name();
4772            let name = name.to_string_lossy();
4773            if !name.ends_with(".log") {
4774                continue;
4775            }
4776            if name.starts_with("Worker_") {
4777                return Self::RanAJob;
4778            }
4779            listener |= name.starts_with("Runner_");
4780        }
4781        if listener { Self::NoJob } else { Self::Unknown }
4782    }
4783}
4784
4785/// The attempt's GitHub runner id: journalled, observed, or from the sidecar
4786/// written at registration.
4787fn known_runner_id(attempt: &RunnerAttempt, observed: Option<u64>) -> Option<u64> {
4788    attempt
4789        .github_runner_id()
4790        .or(observed)
4791        .or_else(|| read_runner_id(attempt.runtime_path()))
4792}
4793
4794fn read_runner_id(runtime: &Path) -> Option<u64> {
4795    fs::read_to_string(runtime.join(RUNNER_ID_FILE))
4796        .ok()?
4797        .trim()
4798        .parse()
4799        .ok()
4800}
4801
4802fn write_runner_id(runtime: &Path, runner_id: u64) -> Result<(), LifecycleError> {
4803    let target = runtime.join(RUNNER_ID_FILE);
4804    if let Some(existing) = read_runner_id(runtime) {
4805        return (existing == runner_id)
4806            .then_some(())
4807            .ok_or(LifecycleError::Journal);
4808    }
4809    let temporary = runtime.join(format!("{RUNNER_ID_FILE}.{}.tmp", uuid::Uuid::new_v4()));
4810    write_durable_file(&temporary, runner_id.to_string().as_bytes())
4811        .map_err(|_| LifecycleError::Journal)?;
4812    match fs::rename(&temporary, &target) {
4813        Ok(()) => sync_directory(runtime).map_err(|_| LifecycleError::Journal),
4814        Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
4815            let _ = fs::remove_file(&temporary);
4816            (read_runner_id(runtime) == Some(runner_id))
4817                .then_some(())
4818                .ok_or(LifecycleError::Journal)
4819        }
4820        Err(_) => {
4821            let _ = fs::remove_file(&temporary);
4822            Err(LifecycleError::Journal)
4823        }
4824    }
4825}
4826
4827fn write_durable_file(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
4828    let mut file = fs::OpenOptions::new()
4829        .create(true)
4830        .truncate(true)
4831        .write(true)
4832        .open(path)?;
4833    file.write_all(bytes)?;
4834    file.sync_all()?;
4835    let parent = path.parent().ok_or_else(|| {
4836        std::io::Error::new(
4837            std::io::ErrorKind::InvalidInput,
4838            "file has no parent directory",
4839        )
4840    })?;
4841    sync_directory(parent)
4842}
4843
4844#[cfg(unix)]
4845fn sync_directory(path: &Path) -> std::io::Result<()> {
4846    fs::File::open(path)?.sync_all()
4847}
4848
4849#[cfg(windows)]
4850fn sync_directory(path: &Path) -> std::io::Result<()> {
4851    use std::os::windows::fs::OpenOptionsExt;
4852
4853    const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000;
4854    const FILE_SHARE_ALL: u32 = 0x0000_0007;
4855    const GENERIC_WRITE: u32 = 0x4000_0000;
4856    fs::OpenOptions::new()
4857        .access_mode(GENERIC_WRITE)
4858        .share_mode(FILE_SHARE_ALL)
4859        .custom_flags(FILE_FLAG_BACKUP_SEMANTICS)
4860        .open(path)?
4861        .sync_all()
4862}
4863
4864#[cfg(test)]
4865mod tests {
4866    use super::*;
4867    use std::collections::BTreeSet;
4868    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
4869
4870    use crate::reconcile::{AllocationLock, InProcessAllocationLock};
4871    use runner_manager_domain::model::{Elapsed, TargetScope};
4872    use runner_manager_domain::store::SqliteStore;
4873    use runner_manager_github::jit::JitRunner;
4874    use runner_manager_testkit::clock::FakeClock;
4875    use runner_manager_testkit::fixtures;
4876
4877    #[test]
4878    fn production_provider_set_routes_every_implemented_isolated_backend() {
4879        let providers = PlatformExecutionProvider::new(HostId::from_u128(1));
4880        assert!(providers.isolated_provider(Backend::Auto).is_some());
4881        assert!(providers.isolated_provider(Backend::Oci).is_some());
4882        assert!(
4883            providers
4884                .isolated_provider(Backend::WindowsHyperVContainer)
4885                .is_some()
4886        );
4887        assert!(
4888            providers
4889                .isolated_provider(Backend::VirtualMachine)
4890                .is_none()
4891        );
4892    }
4893
4894    /// One event's fields, in the order they were recorded.
4895    type CapturedFields = Vec<(String, String)>;
4896
4897    /// Keeps the `(name, value)` fields of every `tracing` event emitted while
4898    /// it is installed.
4899    ///
4900    /// The names are kept and not just the rendered line, so a test can hold the
4901    /// event to `crate::logging`'s two rules -- the field allow-list and the
4902    /// value scrub -- instead of asserting that some string was passed to a
4903    /// macro.
4904    #[derive(Clone, Default)]
4905    struct CapturedEvents(std::sync::Arc<std::sync::Mutex<Vec<CapturedFields>>>);
4906
4907    impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for CapturedEvents {
4908        fn on_event(
4909            &self,
4910            event: &tracing::Event<'_>,
4911            _context: tracing_subscriber::layer::Context<'_, S>,
4912        ) {
4913            struct Collect(Vec<(String, String)>);
4914            impl tracing::field::Visit for Collect {
4915                fn record_debug(
4916                    &mut self,
4917                    field: &tracing::field::Field,
4918                    value: &dyn std::fmt::Debug,
4919                ) {
4920                    // `{value:?}` on a `&str` field would keep the quotes, and
4921                    // the redaction rules are about the value, not its literal.
4922                    self.0.push((
4923                        field.name().to_owned(),
4924                        format!("{value:?}").trim_matches('"').to_owned(),
4925                    ));
4926                }
4927            }
4928            let mut collected = Collect(Vec::new());
4929            event.record(&mut collected);
4930            self.0
4931                .lock()
4932                .expect("the capture mutex is not poisoned")
4933                .push(collected.0);
4934        }
4935    }
4936
4937    /// The regression behind a three-hour outage that showed nothing an operator
4938    /// could act on.
4939    ///
4940    /// A root the daemon cannot use refuses the launch *before*
4941    /// `record_allocation`, so no attempt row is ever written: `b2` has nothing
4942    /// to carry the failure on and `g2` has nothing to show it from. The
4943    /// lifecycle event keeps only the variant -- `failure_reason_kind` allows no
4944    /// free text on an event -- so the whole failure read
4945    /// `runner_start_failed reason=other`, once per poll, for hours.
4946    ///
4947    /// The assertion is deliberately made against the **production redaction
4948    /// rules** and not merely against what was emitted. An earlier attempt at
4949    /// this fix logged the rendered error on a `detail` field and passed a test
4950    /// exactly like this one, while shipping `detail="[redacted]"`: the field
4951    /// was not allow-listed, and had it been, the value is mostly paths and
4952    /// would have been scrubbed to `[path]`. A test that does not ask
4953    /// `crate::logging` what survives is a test that proves nothing.
4954    #[test]
4955    fn a_launch_the_runner_root_refused_names_the_cause_in_the_log_that_ships() {
4956        use runner_manager_platform::logging;
4957        use tracing_subscriber::layer::SubscriberExt as _;
4958
4959        let captured = CapturedEvents::default();
4960        let error = RunnerRootError::DeniedByPrivacyPolicy {
4961            requested: PathBuf::from("/Volumes/NVME/runners"),
4962            refused: PathBuf::from("/Volumes/NVME"),
4963            remediation: RootOwner::Host.remediation(),
4964        };
4965        let kind = error.kind();
4966
4967        let failure = tracing::subscriber::with_default(
4968            tracing_subscriber::registry().with(captured.clone()),
4969            || root_failure(error),
4970        );
4971
4972        // The reason still carries the whole sentence. Nothing on *this* path
4973        // renders it -- there is no attempt row, and the event carries only the
4974        // variant -- so this is a guard against a refactor that drops the detail
4975        // before some future surface can show it, and not a claim that one does.
4976        assert!(
4977            matches!(
4978                &failure,
4979                LifecycleError::Failed(FailureReason::Other(detail))
4980                    if detail.contains("/Volumes/NVME/runners")
4981                        && detail.contains("Full Disk Access")
4982            ),
4983            "the reason must still carry the detail: {failure:?}"
4984        );
4985
4986        let events = captured
4987            .0
4988            .lock()
4989            .expect("the capture mutex is not poisoned")
4990            .clone();
4991        let event = events
4992            .iter()
4993            .find(|fields| fields.iter().any(|(_, value)| value.contains(kind)))
4994            .unwrap_or_else(|| panic!("the refusal did not name its cause: {events:?}"));
4995
4996        // Held to the rules the real sink applies, by name and by value. An
4997        // earlier fix put the rendered error on an unlisted `detail` field and
4998        // shipped `[redacted]`; asserting only that something was emitted would
4999        // have passed then too.
5000        for (name, value) in event {
5001            assert!(
5002                logging::is_field_allowed(name),
5003                "`{name}` is not allow-listed, so it ships as `{}`: {event:?}",
5004                logging::REDACTION
5005            );
5006            assert_eq!(
5007                &logging::redact(value),
5008                value,
5009                "`{name}` does not survive value-shape scrubbing: {event:?}"
5010            );
5011        }
5012    }
5013
5014    /// A slot number, for the tests that name one.
5015    fn nz(slot: u16) -> NonZeroU16 {
5016        NonZeroU16::new(slot).expect("a positive slot")
5017    }
5018
5019    const JIT: &str = "eyJzZWNyZXQiOiJnaHBfRE9fTk9UX0xFQUsifQ==";
5020
5021    #[derive(Debug, Default)]
5022    struct FakeGithubLifecycle {
5023        registration_failures: Mutex<VecDeque<bool>>,
5024        observations: Mutex<VecDeque<LifecycleGithubObservation>>,
5025        registrations: AtomicUsize,
5026        remaining_runners: AtomicUsize,
5027        /// Every runner id `deregister` was asked to remove, in order. A count
5028        /// would not do: the assertions worth making are that the *right*
5029        /// registration was deleted and that it was deleted once.
5030        deregistrations: Mutex<Vec<u64>>,
5031        /// Set to make `deregister` answer `false`, standing for a GitHub that
5032        /// could not be reached at the moment the attempt concluded.
5033        deregistration_fails: AtomicBool,
5034        /// The journal to read *during* a registration, for the ordering
5035        /// assertion `02-target-architecture.md` makes: the slot lease is
5036        /// written "before package or GitHub effects". Reading it afterwards
5037        /// would pass even if the write happened second.
5038        journal: Mutex<Option<Arc<SqliteStore>>>,
5039        /// One entry per registration, in order.
5040        registration_facts: Mutex<Vec<RegistrationFact>>,
5041    }
5042
5043    /// What one JIT registration saw of the world at the moment it was issued.
5044    ///
5045    /// `runner_name` is what ties the other two fields to *one* attempt: with
5046    /// two allocators racing, "some slot was journalled" is a much weaker claim
5047    /// than "the slot this very request belongs to was journalled", and only the
5048    /// name distinguishes them.
5049    #[derive(Debug, Clone)]
5050    struct RegistrationFact {
5051        /// The persistent slots the journal already held.
5052        leased_slots: Vec<u16>,
5053        /// The `work_folder` the request carried.
5054        work_folder: String,
5055        /// The runner name the request carried, i.e. [`runner_name`] of the
5056        /// registering attempt.
5057        runner_name: String,
5058        journal_states: Vec<AttemptState>,
5059    }
5060
5061    impl FakeGithubLifecycle {
5062        fn fail(mut self, terminal: bool) -> Self {
5063            self.registration_failures
5064                .get_mut()
5065                .expect("unpoisoned")
5066                .push_back(terminal);
5067            self
5068        }
5069
5070        fn watch_journal(&self, store: Arc<SqliteStore>) {
5071            *self.journal.lock().unwrap() = Some(store);
5072        }
5073
5074        fn registration_facts(&self) -> Vec<RegistrationFact> {
5075            self.registration_facts.lock().unwrap().clone()
5076        }
5077
5078        fn observe(&self, observation: GithubRunnerObservation) {
5079            let observation = match observation {
5080                GithubRunnerObservation::Unreachable => LifecycleGithubObservation::unreachable(),
5081                GithubRunnerObservation::NotRegistered => {
5082                    LifecycleGithubObservation::not_registered()
5083                }
5084                GithubRunnerObservation::Registered { busy } => {
5085                    LifecycleGithubObservation::registered(73, busy)
5086                }
5087            };
5088            self.observations.lock().unwrap().push_back(observation);
5089        }
5090    }
5091
5092    #[async_trait]
5093    impl LifecycleGithub for FakeGithubLifecycle {
5094        async fn register(
5095            &self,
5096            _target: &ScaleTarget,
5097            request: &JitRunnerRequest,
5098            _cancel: &CancelToken,
5099        ) -> Result<JitRegistration, JitRequestFailure> {
5100            self.registrations.fetch_add(1, Ordering::SeqCst);
5101            if let Some(store) = self.journal.lock().unwrap().as_ref() {
5102                let attempts = store.attempts().expect("the journal is readable");
5103                let slots = attempts
5104                    .iter()
5105                    .filter_map(|attempt| attempt.workspace().slot_number())
5106                    .collect();
5107                self.registration_facts
5108                    .lock()
5109                    .unwrap()
5110                    .push(RegistrationFact {
5111                        leased_slots: slots,
5112                        work_folder: request.work_folder().to_string(),
5113                        runner_name: request.name().to_string(),
5114                        journal_states: attempts.iter().map(RunnerAttempt::state).collect(),
5115                    });
5116            }
5117            if let Some(terminal) = self.registration_failures.lock().unwrap().pop_front() {
5118                return Err(JitRequestFailure {
5119                    terminal,
5120                    reason: if terminal {
5121                        FailureReason::Other("GitHub refused JIT registration with 403".into())
5122                    } else {
5123                        FailureReason::JitRequestFailed
5124                    },
5125                    retry_after: None,
5126                });
5127            }
5128            self.remaining_runners.store(1, Ordering::SeqCst);
5129            Ok(JitRegistration::new(
5130                EncodedJitConfig::new(JIT),
5131                JitRunner {
5132                    id: 73,
5133                    name: request.name().to_string(),
5134                    os: "windows".into(),
5135                    status: "offline".into(),
5136                    busy: false,
5137                    runner_group_id: Some(1),
5138                    labels: request.labels().to_vec(),
5139                },
5140            ))
5141        }
5142
5143        async fn observe(
5144            &self,
5145            _target: &ScaleTarget,
5146            _attempt: AttemptId,
5147            _cancel: &CancelToken,
5148        ) -> LifecycleGithubObservation {
5149            let observation = self
5150                .observations
5151                .lock()
5152                .unwrap()
5153                .pop_front()
5154                .unwrap_or(LifecycleGithubObservation::not_registered());
5155            if observation.status == GithubRunnerObservation::NotRegistered {
5156                self.remaining_runners.store(0, Ordering::SeqCst);
5157            }
5158            observation
5159        }
5160
5161        async fn deregister(
5162            &self,
5163            _target: &ScaleTarget,
5164            runner_id: u64,
5165            _cancel: &CancelToken,
5166        ) -> bool {
5167            self.deregistrations.lock().unwrap().push(runner_id);
5168            if self.deregistration_fails.load(Ordering::SeqCst) {
5169                return false;
5170            }
5171            self.remaining_runners.store(0, Ordering::SeqCst);
5172            true
5173        }
5174    }
5175
5176    #[derive(Debug)]
5177    struct FakePackages {
5178        version: RunnerVersion,
5179        leases: Mutex<BTreeSet<AttemptId>>,
5180        materializations: AtomicUsize,
5181        materialization_failures: AtomicUsize,
5182        release_failures: AtomicUsize,
5183        releases: AtomicUsize,
5184        prunes: AtomicUsize,
5185        prune_currents: Mutex<Vec<RunnerVersion>>,
5186    }
5187
5188    impl Default for FakePackages {
5189        fn default() -> Self {
5190            Self {
5191                version: RunnerVersion::parse("2.330.0").unwrap(),
5192                leases: Mutex::new(BTreeSet::new()),
5193                materializations: AtomicUsize::new(0),
5194                materialization_failures: AtomicUsize::new(0),
5195                release_failures: AtomicUsize::new(0),
5196                releases: AtomicUsize::new(0),
5197                prunes: AtomicUsize::new(0),
5198                prune_currents: Mutex::new(Vec::new()),
5199            }
5200        }
5201    }
5202
5203    impl FakePackages {
5204        fn fail_materializations(&self, count: usize) {
5205            self.materialization_failures.store(count, Ordering::SeqCst);
5206        }
5207
5208        fn fail_releases(&self, count: usize) {
5209            self.release_failures.store(count, Ordering::SeqCst);
5210        }
5211    }
5212
5213    #[async_trait]
5214    impl RuntimePackages for FakePackages {
5215        async fn materialize(
5216            &self,
5217            attempt: &RunnerAttempt,
5218        ) -> Result<RunnerVersion, FailureReason> {
5219            self.materializations.fetch_add(1, Ordering::SeqCst);
5220            if self
5221                .materialization_failures
5222                .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |left| {
5223                    if left > 0 { Some(left - 1) } else { None }
5224                })
5225                .is_ok()
5226            {
5227                return Err(FailureReason::Other(
5228                    "runner package materialization failed transiently".into(),
5229                ));
5230            }
5231            fs::create_dir_all(attempt.runtime_path()).unwrap();
5232            fs::write(attempt.runtime_path().join("runner-package"), b"verified").unwrap();
5233            self.leases.lock().unwrap().insert(attempt.id);
5234            Ok(self.version.clone())
5235        }
5236
5237        fn release(&self, attempt: AttemptId) -> Result<(), FailureReason> {
5238            if self
5239                .release_failures
5240                .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |left| {
5241                    if left > 0 { Some(left - 1) } else { None }
5242                })
5243                .is_ok()
5244            {
5245                return Err(FailureReason::Other(
5246                    "runner package lease could not be released".into(),
5247                ));
5248            }
5249            self.leases.lock().unwrap().remove(&attempt);
5250            self.releases.fetch_add(1, Ordering::SeqCst);
5251            Ok(())
5252        }
5253
5254        fn prune_obsolete_guarded(
5255            &self,
5256            _authority: PruneAuthority<'_>,
5257            current: &RunnerVersion,
5258            _attempts: &[RunnerAttempt],
5259        ) -> Result<(), FailureReason> {
5260            self.prunes.fetch_add(1, Ordering::SeqCst);
5261            self.prune_currents.lock().unwrap().push(current.clone());
5262            Ok(())
5263        }
5264    }
5265
5266    #[derive(Debug, Default)]
5267    struct FakeProcesses {
5268        alive: AtomicBool,
5269        completed_successfully: AtomicBool,
5270        spawns: AtomicUsize,
5271        spawn_failures: AtomicUsize,
5272        live_spawn_failure: AtomicBool,
5273        terminations: AtomicUsize,
5274        intent: AtomicBool,
5275        intent_failure: AtomicBool,
5276        actions: Mutex<Vec<&'static str>>,
5277        saw_secret: AtomicBool,
5278    }
5279
5280    impl FakeProcesses {
5281        fn fail_spawns(&self, count: usize) {
5282            self.spawn_failures.store(count, Ordering::SeqCst);
5283        }
5284
5285        fn fail_spawn_with_live_child(&self) {
5286            self.live_spawn_failure.store(true, Ordering::SeqCst);
5287        }
5288
5289        fn set_alive(&self, alive: bool) {
5290            self.alive.store(alive, Ordering::SeqCst);
5291        }
5292
5293        fn finish_successfully(&self) {
5294            self.completed_successfully.store(true, Ordering::SeqCst);
5295            self.alive.store(false, Ordering::SeqCst);
5296        }
5297
5298        fn fail_intent(&self) {
5299            self.intent_failure.store(true, Ordering::SeqCst);
5300        }
5301    }
5302
5303    impl ProcessSupervisor for FakeProcesses {
5304        fn spawn(
5305            &self,
5306            attempt: &RunnerAttempt,
5307            config: &EncodedJitConfig,
5308        ) -> Result<u32, ProcessStartFailure> {
5309            self.spawns.fetch_add(1, Ordering::SeqCst);
5310            // Model the production handoff on both paths: the sensitive file is
5311            // scoped to this call and absent when it returns.
5312            let handoff = RestrictiveHandoff::create(
5313                attempt.runtime_path(),
5314                SecretString::from(config.expose().to_owned()),
5315            )
5316            .unwrap();
5317            self.saw_secret
5318                .store(config.expose() == JIT, Ordering::SeqCst);
5319            let handoff_path = handoff.path().to_path_buf();
5320            let failing = self
5321                .spawn_failures
5322                .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |left| {
5323                    if left > 0 { Some(left - 1) } else { None }
5324                })
5325                .is_ok();
5326            drop(handoff);
5327            assert!(!handoff_path.exists(), "handoff must be absent on return");
5328            if self.live_spawn_failure.swap(false, Ordering::SeqCst) {
5329                self.alive.store(true, Ordering::SeqCst);
5330                return Err(ProcessStartFailure::after_spawn_live(4242));
5331            }
5332            if failing {
5333                return Err(ProcessStartFailure::before_spawn(
5334                    FailureReason::ProcessStartFailed,
5335                ));
5336            }
5337            self.alive.store(true, Ordering::SeqCst);
5338            Ok(4242)
5339        }
5340
5341        fn is_alive(&self, _attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
5342            self.actions.lock().unwrap().push("observe_process");
5343            Ok(self.alive.load(Ordering::SeqCst))
5344        }
5345
5346        fn recovered_pid(&self, _attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason> {
5347            Ok(self.alive.load(Ordering::SeqCst).then_some(4242))
5348        }
5349
5350        fn completed_successfully(&self, _attempt: &RunnerAttempt) -> bool {
5351            self.completed_successfully.load(Ordering::SeqCst)
5352        }
5353
5354        fn record_terminate_intent(&self, _attempt: &RunnerAttempt) -> Result<(), FailureReason> {
5355            self.actions.lock().unwrap().push("terminate_intent");
5356            if self.intent_failure.load(Ordering::SeqCst) {
5357                return Err(FailureReason::Other(
5358                    "terminate intent directory sync failed".into(),
5359                ));
5360            }
5361            self.intent.store(true, Ordering::SeqCst);
5362            Ok(())
5363        }
5364
5365        fn has_terminate_intent(&self, _attempt: &RunnerAttempt) -> bool {
5366            self.intent.load(Ordering::SeqCst)
5367        }
5368
5369        fn terminate(&self, _attempt: &RunnerAttempt) -> Result<(), FailureReason> {
5370            assert!(
5371                self.intent.load(Ordering::SeqCst),
5372                "the durable intent must exist before signalling"
5373            );
5374            self.actions.lock().unwrap().push("terminate");
5375            self.terminations.fetch_add(1, Ordering::SeqCst);
5376            self.alive.store(false, Ordering::SeqCst);
5377            Ok(())
5378        }
5379    }
5380
5381    #[derive(Debug, Default)]
5382    struct FakeIsolatedProvider {
5383        resources: Mutex<BTreeMap<AttemptId, (EnvironmentIdentity, EnvironmentState)>>,
5384        extra_resources: Mutex<Vec<EnvironmentIdentity>>,
5385        unsupported_host: AtomicBool,
5386        permission_denied: AtomicBool,
5387        incompatible_image: AtomicBool,
5388        prepare_failure: AtomicBool,
5389        recovery_unavailable: AtomicBool,
5390        destroy_deferred: AtomicBool,
5391        handoffs: AtomicUsize,
5392        completed_successfully: AtomicBool,
5393        native_calls: AtomicUsize,
5394        actions: Mutex<Vec<&'static str>>,
5395    }
5396
5397    impl FakeIsolatedProvider {
5398        fn identity(attempt: &RunnerAttempt, host: HostId) -> EnvironmentIdentity {
5399            let AttemptExecution::Isolated {
5400                provider_kind,
5401                resolved_image,
5402                generation,
5403                ..
5404            } = attempt.execution()
5405            else {
5406                panic!("isolated intent required")
5407            };
5408            EnvironmentIdentity::Isolated {
5409                host,
5410                attempt: attempt.id,
5411                provider_kind: *provider_kind,
5412                environment_id: format!("environment-{}", attempt.id),
5413                resolved_image: resolved_image.clone(),
5414                generation: generation.clone(),
5415            }
5416        }
5417
5418        fn resource_count(&self) -> usize {
5419            self.resources.lock().unwrap().len()
5420        }
5421    }
5422
5423    impl ExecutionProvider for FakeIsolatedProvider {
5424        fn probe(&self, policy: &ScalePolicy) -> ProviderCapability {
5425            if self.unsupported_host.load(Ordering::SeqCst) {
5426                ProviderCapability::Unsupported
5427            } else if self.permission_denied.load(Ordering::SeqCst) {
5428                ProviderCapability::PermissionDenied
5429            } else if policy.execution_policy().is_native() {
5430                ProviderCapability::Unsupported
5431            } else {
5432                ProviderCapability::Ready
5433            }
5434        }
5435
5436        fn resolve(
5437            &self,
5438            policy: &ScalePolicy,
5439        ) -> Result<Option<ResolvedEnvironment>, FailureReason> {
5440            let runner_manager_domain::execution::ExecutionPolicy::Isolated { image, .. } =
5441                policy.execution_policy()
5442            else {
5443                return Err(FailureReason::Other("isolated policy required".into()));
5444            };
5445            Ok(Some(ResolvedEnvironment {
5446                provider_kind: Backend::Oci,
5447                image: if self.incompatible_image.load(Ordering::SeqCst) {
5448                    ImageReference::new("vm-version:wrong-provider")
5449                        .expect("valid but incompatible")
5450                } else {
5451                    image.clone()
5452                },
5453            }))
5454        }
5455
5456        fn prepare(
5457            &self,
5458            attempt: &RunnerAttempt,
5459            policy: &ScalePolicy,
5460        ) -> Result<PreparedEnvironment, FailureReason> {
5461            assert_eq!(
5462                attempt.state(),
5463                AttemptState::Preparing,
5464                "intent must be durable before provider effect"
5465            );
5466            self.actions.lock().unwrap().push("prepare");
5467            let identity = Self::identity(attempt, policy.host_id);
5468            self.resources
5469                .lock()
5470                .unwrap()
5471                .insert(attempt.id, (identity.clone(), EnvironmentState::Starting));
5472            if self.prepare_failure.swap(false, Ordering::SeqCst) {
5473                return Err(FailureReason::Other("provider-secret-needle".into()));
5474            }
5475            Ok(PreparedEnvironment::isolated(attempt.id, identity))
5476        }
5477
5478        fn start(
5479            &self,
5480            prepared: PreparedEnvironment,
5481            attempt: &RunnerAttempt,
5482            handoff: OneTimeJitHandoff<'_>,
5483        ) -> Result<EnvironmentIdentity, ProcessStartFailure> {
5484            assert_eq!(prepared.attempt, attempt.id);
5485            assert_eq!(attempt.state(), AttemptState::JitReceived);
5486            assert_eq!(handoff.consume().expose(), JIT);
5487            self.handoffs.fetch_add(1, Ordering::SeqCst);
5488            self.actions.lock().unwrap().push("start");
5489            let mut resources = self.resources.lock().unwrap();
5490            let (identity, state) = resources.get_mut(&attempt.id).expect("prepared resource");
5491            *state = EnvironmentState::Running;
5492            Ok(identity.clone())
5493        }
5494
5495        fn inspect(&self, attempt: &RunnerAttempt) -> Result<EnvironmentState, FailureReason> {
5496            self.actions.lock().unwrap().push("inspect");
5497            Ok(self
5498                .resources
5499                .lock()
5500                .unwrap()
5501                .get(&attempt.id)
5502                .map_or(EnvironmentState::Missing, |(_, state)| *state))
5503        }
5504
5505        fn recover(&self, attempt: &RunnerAttempt) -> Result<EnvironmentState, FailureReason> {
5506            if self.recovery_unavailable.load(Ordering::SeqCst) {
5507                return Err(FailureReason::Other(
5508                    "provider discovery unavailable".into(),
5509                ));
5510            }
5511            self.inspect(attempt)
5512        }
5513
5514        fn owns(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
5515            Ok(self
5516                .resources
5517                .lock()
5518                .unwrap()
5519                .get(&attempt.id)
5520                .is_some_and(|(identity, _)| {
5521                    identity_matches_intent(attempt_host(identity), attempt, identity)
5522                }))
5523        }
5524
5525        fn stop(&self, attempt: &RunnerAttempt) -> Result<ProviderStop, FailureReason> {
5526            assert!(self.owns(attempt)?);
5527            self.actions.lock().unwrap().push("stop");
5528            if let Some((_, state)) = self.resources.lock().unwrap().get_mut(&attempt.id) {
5529                *state = EnvironmentState::Exited;
5530            }
5531            Ok(ProviderStop::Stopped)
5532        }
5533
5534        fn destroy(&self, attempt: &RunnerAttempt) -> Result<ProviderDestroy, FailureReason> {
5535            assert!(self.owns(attempt)?);
5536            self.actions.lock().unwrap().push("destroy");
5537            if self.destroy_deferred.load(Ordering::SeqCst) {
5538                return Ok(ProviderDestroy::Deferred("provider busy"));
5539            }
5540            self.resources.lock().unwrap().remove(&attempt.id);
5541            Ok(ProviderDestroy::Destroyed)
5542        }
5543
5544        fn enumerate_owned(&self, host: HostId) -> Vec<EnvironmentIdentity> {
5545            let mut owned = self
5546                .resources
5547                .lock()
5548                .unwrap()
5549                .values()
5550                .filter(|(identity, _)| attempt_host(identity) == host)
5551                .map(|(identity, _)| identity.clone())
5552                .collect::<Vec<_>>();
5553            owned.extend(
5554                self.extra_resources
5555                    .lock()
5556                    .unwrap()
5557                    .iter()
5558                    .filter(|identity| attempt_host(identity) == host)
5559                    .cloned(),
5560            );
5561            owned
5562        }
5563
5564        fn diagnostics(&self, _attempt: &RunnerAttempt) -> Vec<ProviderDiagnostic> {
5565            vec![ProviderDiagnostic::CleanupDeferred; 40]
5566        }
5567
5568        fn spawn(
5569            &self,
5570            _attempt: &RunnerAttempt,
5571            _config: &EncodedJitConfig,
5572        ) -> Result<u32, ProcessStartFailure> {
5573            self.native_calls.fetch_add(1, Ordering::SeqCst);
5574            panic!("isolated provider must never spawn natively")
5575        }
5576        fn is_alive(&self, _attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
5577            panic!("native fallback")
5578        }
5579        fn recovered_pid(&self, _attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason> {
5580            panic!("native fallback")
5581        }
5582        fn completed_successfully(&self, _attempt: &RunnerAttempt) -> bool {
5583            self.completed_successfully.load(Ordering::SeqCst)
5584        }
5585        fn record_terminate_intent(&self, _attempt: &RunnerAttempt) -> Result<(), FailureReason> {
5586            panic!("native fallback")
5587        }
5588        fn has_terminate_intent(&self, _attempt: &RunnerAttempt) -> bool {
5589            false
5590        }
5591        fn terminate(&self, _attempt: &RunnerAttempt) -> Result<(), FailureReason> {
5592            panic!("native fallback")
5593        }
5594    }
5595
5596    fn attempt_host(identity: &EnvironmentIdentity) -> HostId {
5597        let EnvironmentIdentity::Isolated { host, .. } = identity else {
5598            panic!("isolated identity")
5599        };
5600        *host
5601    }
5602
5603    #[derive(Debug, Default)]
5604    struct FakeDemand {
5605        answers: Mutex<VecDeque<bool>>,
5606    }
5607
5608    impl FakeDemand {
5609        fn answering(answers: impl IntoIterator<Item = bool>) -> Self {
5610            Self {
5611                answers: Mutex::new(answers.into_iter().collect()),
5612            }
5613        }
5614    }
5615
5616    #[async_trait]
5617    impl DemandPersistence for FakeDemand {
5618        async fn persists(&self, _policy: PolicyId) -> bool {
5619            self.answers.lock().unwrap().pop_front().unwrap_or(true)
5620        }
5621    }
5622
5623    #[derive(Debug, Default)]
5624    struct FakeDelay(Mutex<Vec<Duration>>);
5625
5626    #[async_trait]
5627    impl RetryDelay for FakeDelay {
5628        async fn wait(&self, duration: Duration) {
5629            self.0.lock().unwrap().push(duration);
5630        }
5631    }
5632
5633    struct Harness {
5634        _root: tempfile::TempDir,
5635        app_paths: runner_manager_platform::paths::AppPaths,
5636        launcher: LifecycleLauncher,
5637        demand: Arc<dyn DemandPersistence>,
5638        store: Arc<SqliteStore>,
5639        github: Arc<FakeGithubLifecycle>,
5640        packages: Arc<FakePackages>,
5641        processes: Arc<FakeProcesses>,
5642        clock: Arc<FakeClock>,
5643        events: Arc<AttemptEventLog>,
5644        reconcile_events: Arc<crate::reconcile::EventLog>,
5645        delay: Arc<FakeDelay>,
5646        host: runner_manager_domain::model::Host,
5647        policy: ScalePolicy,
5648        allocation_lock: InProcessAllocationLock,
5649        /// The repository persistent root, once one is configured.
5650        workspace_root: Option<LocalAbsolutePath>,
5651    }
5652
5653    impl Harness {
5654        fn new(github: FakeGithubLifecycle, demand: Arc<dyn DemandPersistence>) -> Self {
5655            let root = tempfile::tempdir().unwrap();
5656            let paths = runner_manager_platform::paths::AppPaths::rooted_at(root.path());
5657            paths.create_all().unwrap();
5658            let policy = fixtures::policy()
5659                .repository("octo/repo")
5660                .autoscale("home", 2)
5661                .active()
5662                .build();
5663            // A unit harness must never inspect or write the workstation's
5664            // production runner root. On Windows that is C:\rman, which is
5665            // intentionally inaccessible to an ordinary account after a boot
5666            // service is installed. Using it made the same suite pass on a
5667            // clean CI image and fail on a correctly secured developer host.
5668            let host_root = root.path().join("host-root");
5669            fs::create_dir_all(&host_root).unwrap();
5670            let mut host = fixtures::host().build();
5671            host.runner_root_override = Some(
5672                LocalAbsolutePath::new(host_root.to_str().expect("a UTF-8 temporary path"))
5673                    .expect("a local absolute host root"),
5674            );
5675            let store = Arc::new(SqliteStore::open_in_memory().unwrap());
5676            store.put_host(&host).unwrap();
5677            let github = Arc::new(github);
5678            let packages = Arc::new(FakePackages::default());
5679            let processes = Arc::new(FakeProcesses::default());
5680            let clock = Arc::new(FakeClock::default());
5681            let events = Arc::new(AttemptEventLog::default());
5682            let reconcile_events = Arc::new(crate::reconcile::EventLog::new());
5683            let delay = Arc::new(FakeDelay::default());
5684            let ports = LifecyclePorts {
5685                store: Arc::clone(&store) as Arc<dyn Store>,
5686                github: Arc::clone(&github) as Arc<dyn LifecycleGithub>,
5687                packages: Arc::clone(&packages) as Arc<dyn RuntimePackages>,
5688                processes: Arc::clone(&processes) as Arc<dyn ProcessSupervisor>,
5689                clock: Arc::clone(&clock) as Arc<dyn Clock>,
5690                demand: Arc::clone(&demand),
5691                delay: Arc::clone(&delay) as Arc<dyn RetryDelay>,
5692                events: Arc::clone(&events) as Arc<dyn AttemptEventSink>,
5693                reconcile_events: Arc::clone(&reconcile_events) as Arc<dyn EventSink>,
5694            };
5695            let launcher = Self::launcher_over(policy.host_id, &paths, ports);
5696            Self {
5697                _root: root,
5698                app_paths: paths,
5699                launcher,
5700                demand,
5701                store,
5702                github,
5703                packages,
5704                processes,
5705                clock,
5706                events,
5707                reconcile_events,
5708                delay,
5709                host,
5710                policy,
5711                allocation_lock: InProcessAllocationLock::new(),
5712                workspace_root: None,
5713            }
5714        }
5715
5716        /// Put disposable attempts under a host root of this harness's own.
5717        ///
5718        /// Without it the launcher resolves the *platform* default, which on
5719        /// Windows is `%SystemDrive%\rman` — a real directory on the machine
5720        /// running the suite. Every test added by `c2` places its files inside
5721        /// its own temporary directory instead.
5722        fn with_host_runner_root(mut self) -> Self {
5723            let host_root = self.host_root();
5724            fs::create_dir_all(&host_root).unwrap();
5725            self.host.runner_root_override = Some(
5726                LocalAbsolutePath::new(host_root.to_str().expect("a UTF-8 temporary path"))
5727                    .expect("a local absolute host root"),
5728            );
5729            self.store.put_host(&self.host).unwrap();
5730            self
5731        }
5732
5733        /// Opt this harness's repository into a persistent workspace (D4).
5734        fn with_persistent_workspace(mut self, capacity: u16) -> Self {
5735            self = self.with_host_runner_root();
5736            let root = self._root.path().join("persist");
5737            let root = LocalAbsolutePath::new(root.to_str().expect("a UTF-8 temporary path"))
5738                .expect("a local absolute workspace root");
5739            self.policy = fixtures::policy()
5740                .repository("octo/repo")
5741                .autoscale("home", capacity)
5742                .active()
5743                .build();
5744            self.policy
5745                .set_workspace_policy(
5746                    WorkspacePolicy::persistent(root.clone(), TargetScope::Repository)
5747                        .expect("a repository may be persistent"),
5748                )
5749                .expect("a repository may be persistent");
5750            self.workspace_root = Some(root);
5751            // The journal's copy, so that cleanup's cross-check against a
5752            // surviving policy is exercised rather than skipped.
5753            self.store.insert_policy(&self.policy).unwrap();
5754            self
5755        }
5756
5757        fn workspace_root(&self) -> &LocalAbsolutePath {
5758            self.workspace_root
5759                .as_ref()
5760                .expect("this harness configured a persistent workspace")
5761        }
5762
5763        fn slot_path(&self, slot: u16) -> PathBuf {
5764            self.workspace_root().as_path().join(format!("s{slot}"))
5765        }
5766
5767        fn host_root(&self) -> PathBuf {
5768            self._root.path().join("host-root")
5769        }
5770
5771        fn attempt(&self, id: AttemptId) -> RunnerAttempt {
5772            self.store
5773                .attempt(id)
5774                .unwrap()
5775                .expect("the attempt is journalled")
5776        }
5777
5778        /// Conclude an attempt and run the real cleanup over it.
5779        fn conclude(&self, id: AttemptId) -> RunnerAttempt {
5780            let mut attempt = self.attempt(id);
5781            attempt
5782                .conclude(
5783                    AttemptOutcome::failed(FailureReason::ProcessExitedUnexpectedly),
5784                    self.clock.now(),
5785                )
5786                .unwrap();
5787            self.store.record_attempt(&attempt).unwrap();
5788            attempt
5789        }
5790
5791        async fn cleanup_retaining_work(&self, id: AttemptId) {
5792            self.conclude(id);
5793            self.launcher
5794                .clean(id)
5795                .await
5796                .expect("the slot is scrubbed and the lease released");
5797        }
5798
5799        /// The launcher configuration every launcher in this harness shares, so
5800        /// that the one a restart mints cannot drift from the original.
5801        fn launcher_over(
5802            host: HostId,
5803            paths: &runner_manager_platform::paths::AppPaths,
5804            ports: LifecyclePorts,
5805        ) -> LifecycleLauncher {
5806            LifecycleLauncher::new(
5807                host,
5808                paths.clone(),
5809                paths.logs_dir(),
5810                1,
5811                RecoveryTimeouts::new(
5812                    Elapsed::seconds(10),
5813                    Elapsed::seconds(10),
5814                    Elapsed::seconds(10),
5815                ),
5816                RetryPolicy::bounded(3, Duration::from_millis(10), Duration::from_millis(25)),
5817                ports,
5818            )
5819        }
5820
5821        /// The same journal, the same directories, a launcher that remembers
5822        /// nothing — which is what a daemon restart is.
5823        fn restart(&self) -> LifecycleLauncher {
5824            Self::launcher_over(
5825                self.policy.host_id,
5826                &self.app_paths,
5827                LifecyclePorts {
5828                    store: Arc::clone(&self.store) as Arc<dyn Store>,
5829                    github: Arc::clone(&self.github) as Arc<dyn LifecycleGithub>,
5830                    packages: Arc::clone(&self.packages) as Arc<dyn RuntimePackages>,
5831                    processes: Arc::clone(&self.processes) as Arc<dyn ProcessSupervisor>,
5832                    clock: Arc::clone(&self.clock) as Arc<dyn Clock>,
5833                    demand: Arc::clone(&self.demand),
5834                    delay: Arc::clone(&self.delay) as Arc<dyn RetryDelay>,
5835                    events: Arc::clone(&self.events) as Arc<dyn AttemptEventSink>,
5836                    reconcile_events: Arc::clone(&self.reconcile_events) as Arc<dyn EventSink>,
5837                },
5838            )
5839        }
5840
5841        async fn ready(&self) {
5842            self.launcher
5843                .recover_startup(std::slice::from_ref(&self.policy))
5844                .await
5845                .unwrap();
5846        }
5847
5848        async fn launch(&self) -> RunnerAttempt {
5849            self.launch_result().await.unwrap()
5850        }
5851
5852        async fn launch_result(&self) -> Result<RunnerAttempt, LaunchFailure> {
5853            self.launch_after_waiting(Duration::ZERO).await
5854        }
5855
5856        async fn launch_after_waiting(
5857            &self,
5858            lock_wait: Duration,
5859        ) -> Result<RunnerAttempt, LaunchFailure> {
5860            let guard = self.allocation_lock.acquire().await.unwrap();
5861            self.launcher
5862                .launch(LaunchRequest {
5863                    host: &self.host,
5864                    policy: &self.policy,
5865                    allocation_guard: &guard,
5866                    lock_wait,
5867                })
5868                .await
5869        }
5870
5871        fn only_attempt(&self) -> RunnerAttempt {
5872            self.store.attempts().unwrap().into_iter().next().unwrap()
5873        }
5874    }
5875
5876    fn isolated_harness() -> Harness {
5877        use runner_manager_domain::execution::{ExecutionPolicy, ResourceLimits};
5878        let mut harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
5879            .with_host_runner_root();
5880        let image =
5881            ImageReference::new(format!("registry.example/runner@sha256:{}", "a".repeat(64)))
5882                .expect("pinned image");
5883        harness
5884            .policy
5885            .set_execution_policy(ExecutionPolicy::Isolated {
5886                backend: Backend::Oci,
5887                image,
5888                resources: ResourceLimits {
5889                    cpu_millis: 1000,
5890                    memory_mib: 1024,
5891                    disk_mib: 4096,
5892                },
5893            })
5894            .expect("isolated policy");
5895        harness.github.watch_journal(Arc::clone(&harness.store));
5896        harness
5897    }
5898
5899    fn launcher_with_isolated(
5900        harness: &Harness,
5901        provider: Arc<FakeIsolatedProvider>,
5902    ) -> LifecycleLauncher {
5903        Harness::launcher_over(
5904            harness.policy.host_id,
5905            &harness.app_paths,
5906            LifecyclePorts {
5907                store: Arc::clone(&harness.store) as Arc<dyn Store>,
5908                github: Arc::clone(&harness.github) as Arc<dyn LifecycleGithub>,
5909                packages: Arc::clone(&harness.packages) as Arc<dyn RuntimePackages>,
5910                processes: provider as Arc<dyn ExecutionProvider>,
5911                clock: Arc::clone(&harness.clock) as Arc<dyn Clock>,
5912                demand: Arc::clone(&harness.demand),
5913                delay: Arc::clone(&harness.delay) as Arc<dyn RetryDelay>,
5914                events: Arc::clone(&harness.events) as Arc<dyn AttemptEventSink>,
5915                reconcile_events: Arc::clone(&harness.reconcile_events) as Arc<dyn EventSink>,
5916            },
5917        )
5918    }
5919
5920    #[tokio::test]
5921    async fn isolated_policy_and_journal_never_enter_native_provider_or_jit() {
5922        use runner_manager_domain::execution::{
5923            AttemptExecution, Backend, ExecutionPolicy, ImageReference, ResourceLimits,
5924        };
5925
5926        let mut harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
5927            .with_host_runner_root();
5928        let image =
5929            ImageReference::new(format!("registry.example/runner@sha256:{}", "a".repeat(64)))
5930                .expect("pinned image");
5931        harness
5932            .policy
5933            .set_execution_policy(ExecutionPolicy::Isolated {
5934                backend: Backend::Oci,
5935                image: image.clone(),
5936                resources: ResourceLimits {
5937                    cpu_millis: 1000,
5938                    memory_mib: 1024,
5939                    disk_mib: 4096,
5940                },
5941            })
5942            .expect("valid isolated profile");
5943
5944        harness.ready().await;
5945        assert_eq!(
5946            harness.processes.probe(&harness.policy),
5947            ProviderCapability::Unsupported
5948        );
5949        let failure = harness
5950            .launch_result()
5951            .await
5952            .expect_err("provider is unavailable");
5953        assert_eq!(
5954            failure.reason,
5955            FailureReason::IsolationProvider(IsolationProviderFailure::Unsupported)
5956        );
5957
5958        let runtime = harness.host_root().join("isolated-owned-environment");
5959        fs::create_dir_all(&runtime).expect("provider resource fixture");
5960        fs::write(runtime.join("owned-marker"), "non-secret").expect("fixture marker");
5961        let mut attempt = RunnerAttempt::allocate(
5962            AttemptId::from_u128(0x123),
5963            harness.policy.id,
5964            runtime.clone(),
5965            harness.clock.now(),
5966        );
5967        attempt
5968            .allocate_execution(AttemptExecution::Isolated {
5969                provider_kind: Backend::Oci,
5970                environment_id: None,
5971                resolved_image: image,
5972                generation: "generation-123".into(),
5973            })
5974            .expect("isolated intent");
5975        attempt
5976            .prepared_environment("environment-123".into())
5977            .expect("prepared identity");
5978        harness
5979            .store
5980            .record_attempt(&attempt)
5981            .expect("durable isolated journal");
5982
5983        harness
5984            .launcher
5985            .recover_startup(std::slice::from_ref(&harness.policy))
5986            .await
5987            .expect("unsupported isolated cleanup is profile-local");
5988        let failure = harness
5989            .launch_result()
5990            .await
5991            .expect_err("provider unavailable for this policy");
5992        assert_eq!(
5993            failure.reason,
5994            FailureReason::IsolationProvider(IsolationProviderFailure::Unsupported)
5995        );
5996        harness
5997            .launcher
5998            .supervise(&harness.policy)
5999            .await
6000            .expect("unavailable attempt is deferred");
6001        assert!(harness.launcher.clean(attempt.id).await.is_err());
6002        assert_eq!(
6003            harness.store.attempt(attempt.id).unwrap().unwrap().state(),
6004            AttemptState::CleanupDeferred
6005        );
6006        assert!(matches!(
6007            harness.launcher.recover_startup(&[]).await,
6008            Err(LifecycleError::RecoveryIncomplete)
6009        ));
6010        assert!(
6011            runtime.join("owned-marker").is_file(),
6012            "native cleanup touched the isolated environment"
6013        );
6014        assert!(
6015            harness.processes.actions.lock().unwrap().is_empty(),
6016            "native recovery inspected an isolated identity"
6017        );
6018        assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 0);
6019        assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
6020        assert_eq!(harness.packages.materializations.load(Ordering::SeqCst), 0);
6021    }
6022
6023    #[tokio::test]
6024    async fn isolated_prepare_is_durable_before_jit_and_start_uses_one_handoff() {
6025        let harness = isolated_harness();
6026        let provider = Arc::new(FakeIsolatedProvider::default());
6027        let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
6028        launcher
6029            .recover_startup(std::slice::from_ref(&harness.policy))
6030            .await
6031            .expect("ready");
6032        let guard = harness.allocation_lock.acquire().await.unwrap();
6033        let attempt = launcher
6034            .launch(LaunchRequest {
6035                host: &harness.host,
6036                policy: &harness.policy,
6037                allocation_guard: &guard,
6038                lock_wait: Duration::ZERO,
6039            })
6040            .await
6041            .expect("isolated launch");
6042        assert_eq!(attempt.state(), AttemptState::Starting);
6043        assert_eq!(provider.resource_count(), 1);
6044        assert_eq!(provider.handoffs.load(Ordering::SeqCst), 1);
6045        assert_eq!(provider.native_calls.load(Ordering::SeqCst), 0);
6046        assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 1);
6047        let fact = harness
6048            .github
6049            .registration_facts()
6050            .pop()
6051            .expect("JIT registration");
6052        assert_eq!(
6053            fact.journal_states,
6054            vec![AttemptState::Prepared],
6055            "provider readiness preceded GitHub JIT"
6056        );
6057        assert_eq!(
6058            harness
6059                .events
6060                .events()
6061                .into_iter()
6062                .filter_map(|event| match event {
6063                    AttemptEvent::State { state, .. } => Some(state),
6064                    _ => None,
6065                })
6066                .collect::<Vec<_>>(),
6067            vec![
6068                AttemptState::Allocated,
6069                AttemptState::Preparing,
6070                AttemptState::Prepared,
6071                AttemptState::JitReceived,
6072                AttemptState::Starting
6073            ]
6074        );
6075        let restarted = launcher_with_isolated(&harness, Arc::clone(&provider));
6076        restarted
6077            .recover_startup(std::slice::from_ref(&harness.policy))
6078            .await
6079            .expect("adopt live sandbox");
6080        assert_eq!(provider.resource_count(), 1);
6081        assert_eq!(
6082            harness.github.registrations.load(Ordering::SeqCst),
6083            1,
6084            "restart cannot issue duplicate JIT"
6085        );
6086        harness
6087            .github
6088            .observe(GithubRunnerObservation::Registered { busy: true });
6089        restarted
6090            .supervise(&harness.policy)
6091            .await
6092            .expect("job observed");
6093        assert_eq!(
6094            harness.store.attempt(attempt.id).unwrap().unwrap().state(),
6095            AttemptState::Busy
6096        );
6097        provider
6098            .resources
6099            .lock()
6100            .unwrap()
6101            .get_mut(&attempt.id)
6102            .unwrap()
6103            .1 = EnvironmentState::Exited;
6104        harness
6105            .github
6106            .observe(GithubRunnerObservation::NotRegistered);
6107        restarted
6108            .supervise(&harness.policy)
6109            .await
6110            .expect("terminal cleanup");
6111        assert_eq!(provider.resource_count(), 0);
6112        assert_eq!(
6113            harness.store.attempt(attempt.id).unwrap().unwrap().state(),
6114            AttemptState::Cleaned
6115        );
6116    }
6117
6118    #[tokio::test]
6119    async fn attached_isolated_job_exit_is_recorded_as_completed() {
6120        let harness = isolated_harness();
6121        let provider = Arc::new(FakeIsolatedProvider::default());
6122        let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
6123        launcher
6124            .recover_startup(std::slice::from_ref(&harness.policy))
6125            .await
6126            .unwrap();
6127        let guard = harness.allocation_lock.acquire().await.unwrap();
6128        let attempt = launcher
6129            .launch(LaunchRequest {
6130                host: &harness.host,
6131                policy: &harness.policy,
6132                allocation_guard: &guard,
6133                lock_wait: Duration::ZERO,
6134            })
6135            .await
6136            .unwrap();
6137        harness
6138            .github
6139            .observe(GithubRunnerObservation::Registered { busy: true });
6140        launcher.supervise(&harness.policy).await.unwrap();
6141        provider
6142            .resources
6143            .lock()
6144            .unwrap()
6145            .get_mut(&attempt.id)
6146            .unwrap()
6147            .1 = EnvironmentState::Exited;
6148        provider
6149            .completed_successfully
6150            .store(true, Ordering::SeqCst);
6151        harness
6152            .github
6153            .observe(GithubRunnerObservation::NotRegistered);
6154        launcher.supervise(&harness.policy).await.unwrap();
6155        let cleaned = harness.store.attempt(attempt.id).unwrap().unwrap();
6156        assert_eq!(cleaned.outcome(), Some(&AttemptOutcome::CompletedJob));
6157        assert_eq!(cleaned.state(), AttemptState::Cleaned);
6158    }
6159
6160    #[tokio::test]
6161    async fn isolated_prepare_failure_stays_local_and_crash_gap_recovers_owned_resource() {
6162        let harness = isolated_harness();
6163        let provider = Arc::new(FakeIsolatedProvider::default());
6164        provider.prepare_failure.store(true, Ordering::SeqCst);
6165        let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
6166        launcher
6167            .recover_startup(std::slice::from_ref(&harness.policy))
6168            .await
6169            .expect("ready");
6170        let guard = harness.allocation_lock.acquire().await.unwrap();
6171        let failure = launcher
6172            .launch(LaunchRequest {
6173                host: &harness.host,
6174                policy: &harness.policy,
6175                allocation_guard: &guard,
6176                lock_wait: Duration::ZERO,
6177            })
6178            .await
6179            .expect_err("prepare failed after creating resource");
6180        assert_eq!(
6181            failure.reason,
6182            FailureReason::IsolationProvider(IsolationProviderFailure::RuntimeOperationFailed)
6183        );
6184        assert_eq!(provider.resource_count(), 1);
6185        assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
6186        assert!(
6187            !harness
6188                .store
6189                .dump_text()
6190                .unwrap()
6191                .contains("provider-secret-needle")
6192        );
6193        let id = harness.store.attempts().unwrap()[0].id;
6194        assert_eq!(
6195            harness.store.attempt(id).unwrap().unwrap().outcome(),
6196            Some(&AttemptOutcome::failed(FailureReason::IsolationProvider(
6197                IsolationProviderFailure::RuntimeOperationFailed
6198            )))
6199        );
6200        let restarted = launcher_with_isolated(&harness, Arc::clone(&provider));
6201        restarted
6202            .recover_startup(std::slice::from_ref(&harness.policy))
6203            .await
6204            .expect("profile-local recovery");
6205        assert_eq!(
6206            provider.resource_count(),
6207            0,
6208            "the exact owned resource was destroyed once"
6209        );
6210        assert_eq!(
6211            harness.store.attempt(id).unwrap().unwrap().state(),
6212            AttemptState::Cleaned
6213        );
6214        let diagnostic =
6215            fs::read_to_string(restarted.diagnostics_root.join(format!("{id}.log"))).unwrap();
6216        assert!(diagnostic.contains("reason=isolation_runtime_operation_failed"));
6217        assert!(!diagnostic.contains("provider-secret-needle"));
6218        assert_eq!(
6219            provider
6220                .actions
6221                .lock()
6222                .unwrap()
6223                .iter()
6224                .filter(|action| **action == "destroy")
6225                .count(),
6226            1
6227        );
6228        assert_eq!(provider.native_calls.load(Ordering::SeqCst), 0);
6229    }
6230
6231    #[test]
6232    fn isolation_capability_and_adapter_failures_keep_closed_categories() {
6233        let cases = [
6234            (
6235                ProviderCapability::NotInstalled,
6236                IsolationProviderFailure::NotInstalled,
6237            ),
6238            (
6239                ProviderCapability::PermissionDenied,
6240                IsolationProviderFailure::PermissionDenied,
6241            ),
6242            (
6243                ProviderCapability::ImageUnavailableOrIncompatible,
6244                IsolationProviderFailure::ImageUnavailableOrIncompatible,
6245            ),
6246            (
6247                ProviderCapability::DiskQuotaUnavailable,
6248                IsolationProviderFailure::DiskQuotaUnavailable,
6249            ),
6250            (
6251                ProviderCapability::Degraded,
6252                IsolationProviderFailure::Degraded,
6253            ),
6254        ];
6255        for (capability, category) in cases {
6256            assert_eq!(
6257                capability.refusal(),
6258                FailureReason::IsolationProvider(category)
6259            );
6260        }
6261        let raw = FailureReason::Other("ghp_secret_from_provider".into());
6262        let safe = safe_isolation_reason(raw, IsolationProviderFailure::RuntimeOperationFailed);
6263        assert_eq!(
6264            safe,
6265            FailureReason::IsolationProvider(IsolationProviderFailure::RuntimeOperationFailed)
6266        );
6267        assert!(!safe.to_string().contains("ghp_secret"));
6268        assert_eq!(
6269            safe_isolation_reason(
6270                FailureReason::IsolationProvider(IsolationProviderFailure::PermissionDenied),
6271                IsolationProviderFailure::RuntimeOperationFailed,
6272            ),
6273            FailureReason::IsolationProvider(IsolationProviderFailure::PermissionDenied)
6274        );
6275    }
6276
6277    #[tokio::test]
6278    async fn isolated_permission_and_image_failures_stop_before_jit_without_native_fallback() {
6279        for image_failure in [false, true] {
6280            let harness = isolated_harness();
6281            let provider = Arc::new(FakeIsolatedProvider::default());
6282            provider
6283                .permission_denied
6284                .store(!image_failure, Ordering::SeqCst);
6285            provider
6286                .incompatible_image
6287                .store(image_failure, Ordering::SeqCst);
6288            let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
6289            launcher
6290                .recover_startup(std::slice::from_ref(&harness.policy))
6291                .await
6292                .unwrap();
6293            let guard = harness.allocation_lock.acquire().await.unwrap();
6294            let error = launcher
6295                .launch(LaunchRequest {
6296                    host: &harness.host,
6297                    policy: &harness.policy,
6298                    allocation_guard: &guard,
6299                    lock_wait: Duration::ZERO,
6300                })
6301                .await
6302                .expect_err("pre-JIT refusal");
6303            let expected = if image_failure {
6304                "resolved image is incompatible"
6305            } else {
6306                "permission denied"
6307            };
6308            assert!(error.reason.to_string().contains(expected));
6309            assert!(
6310                harness.store.attempts().unwrap().is_empty(),
6311                "no allocation was journalled"
6312            );
6313            assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
6314            assert_eq!(provider.handoffs.load(Ordering::SeqCst), 0);
6315            assert_eq!(provider.native_calls.load(Ordering::SeqCst), 0);
6316        }
6317    }
6318
6319    #[tokio::test]
6320    async fn unsupported_host_stops_before_jit_without_allocation_or_native_fallback() {
6321        let harness = isolated_harness();
6322        let provider = Arc::new(FakeIsolatedProvider::default());
6323        provider.unsupported_host.store(true, Ordering::SeqCst);
6324        let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
6325        launcher
6326            .recover_startup(std::slice::from_ref(&harness.policy))
6327            .await
6328            .unwrap();
6329        let guard = harness.allocation_lock.acquire().await.unwrap();
6330        let error = launcher
6331            .launch(LaunchRequest {
6332                host: &harness.host,
6333                policy: &harness.policy,
6334                allocation_guard: &guard,
6335                lock_wait: Duration::ZERO,
6336            })
6337            .await
6338            .expect_err("unsupported host must be refused before JIT");
6339
6340        assert_eq!(
6341            error.reason,
6342            FailureReason::IsolationProvider(IsolationProviderFailure::Unsupported)
6343        );
6344        assert!(harness.store.attempts().unwrap().is_empty());
6345        assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
6346        assert_eq!(provider.handoffs.load(Ordering::SeqCst), 0);
6347        assert_eq!(provider.native_calls.load(Ordering::SeqCst), 0);
6348        assert!(provider.actions.lock().unwrap().is_empty());
6349    }
6350
6351    #[tokio::test]
6352    async fn no_journalled_environment_id_waits_for_provider_absence_proof() {
6353        let harness = isolated_harness();
6354        let provider = Arc::new(FakeIsolatedProvider::default());
6355        let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
6356        let runtime = harness.host_root().join("prepare-without-identity");
6357        fs::create_dir_all(&runtime).unwrap();
6358        let mut attempt = RunnerAttempt::allocate(
6359            AttemptId::from_u128(0x9191),
6360            harness.policy.id,
6361            runtime,
6362            harness.clock.now(),
6363        );
6364        let runner_manager_domain::execution::ExecutionPolicy::Isolated { image, .. } =
6365            harness.policy.execution_policy()
6366        else {
6367            unreachable!()
6368        };
6369        attempt
6370            .allocate_execution(AttemptExecution::Isolated {
6371                provider_kind: Backend::Oci,
6372                environment_id: None,
6373                resolved_image: image.clone(),
6374                generation: "prepare-generation".into(),
6375            })
6376            .unwrap();
6377        attempt.begin_prepare(harness.clock.now()).unwrap();
6378        attempt
6379            .conclude(
6380                AttemptOutcome::failed(FailureReason::Other("prepare interrupted".into())),
6381                harness.clock.now(),
6382            )
6383            .unwrap();
6384        harness.store.record_attempt(&attempt).unwrap();
6385
6386        provider.recovery_unavailable.store(true, Ordering::SeqCst);
6387        launcher
6388            .clean(attempt.id)
6389            .await
6390            .expect_err("absence unproven");
6391        let deferred = harness.store.attempt(attempt.id).unwrap().unwrap();
6392        assert_eq!(deferred.state(), AttemptState::CleanupDeferred);
6393        assert!(deferred.counts_against_capacity());
6394
6395        provider.recovery_unavailable.store(false, Ordering::SeqCst);
6396        launcher.clean(attempt.id).await.expect("absence proven");
6397        assert_eq!(
6398            harness.store.attempt(attempt.id).unwrap().unwrap().state(),
6399            AttemptState::Cleaned
6400        );
6401    }
6402
6403    #[tokio::test]
6404    async fn isolated_terminal_capacity_waits_for_confirmed_absence_and_generation_matches() {
6405        let harness = isolated_harness();
6406        let provider = Arc::new(FakeIsolatedProvider::default());
6407        let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
6408        launcher
6409            .recover_startup(std::slice::from_ref(&harness.policy))
6410            .await
6411            .expect("ready");
6412        let guard = harness.allocation_lock.acquire().await.unwrap();
6413        let mut attempt = launcher
6414            .launch(LaunchRequest {
6415                host: &harness.host,
6416                policy: &harness.policy,
6417                allocation_guard: &guard,
6418                lock_wait: Duration::ZERO,
6419            })
6420            .await
6421            .expect("launch");
6422        attempt
6423            .conclude(
6424                AttemptOutcome::failed(FailureReason::ProcessStartFailed),
6425                harness.clock.now(),
6426            )
6427            .unwrap();
6428        harness.store.record_attempt(&attempt).unwrap();
6429        provider.destroy_deferred.store(true, Ordering::SeqCst);
6430        launcher
6431            .clean(attempt.id)
6432            .await
6433            .expect_err("provider deferred destroy");
6434        let deferred = harness.store.attempt(attempt.id).unwrap().unwrap();
6435        assert_eq!(deferred.state(), AttemptState::CleanupDeferred);
6436        assert!(deferred.counts_against_capacity());
6437        assert_eq!(provider.resource_count(), 1);
6438        provider.destroy_deferred.store(false, Ordering::SeqCst);
6439        let restarted = launcher_with_isolated(&harness, Arc::clone(&provider));
6440        restarted
6441            .recover_startup(std::slice::from_ref(&harness.policy))
6442            .await
6443            .expect("retry cleanup");
6444        assert_eq!(provider.resource_count(), 0);
6445        assert!(
6446            !harness
6447                .store
6448                .attempt(attempt.id)
6449                .unwrap()
6450                .unwrap()
6451                .counts_against_capacity()
6452        );
6453
6454        // A reused runtime name with a different generation is not ours to delete.
6455        let second_id = AttemptId::from_u128(0x9999);
6456        let second_runtime = harness.host_root().join("generation-mismatch");
6457        fs::create_dir_all(&second_runtime).unwrap();
6458        let mut terminal = RunnerAttempt::allocate(
6459            second_id,
6460            harness.policy.id,
6461            second_runtime,
6462            harness.clock.now(),
6463        );
6464        let runner_manager_domain::execution::ExecutionPolicy::Isolated { image, .. } =
6465            harness.policy.execution_policy()
6466        else {
6467            unreachable!()
6468        };
6469        terminal
6470            .allocate_execution(AttemptExecution::Isolated {
6471                provider_kind: Backend::Oci,
6472                environment_id: None,
6473                resolved_image: image.clone(),
6474                generation: "expected-generation".into(),
6475            })
6476            .unwrap();
6477        let mut stranger = FakeIsolatedProvider::identity(&terminal, harness.policy.host_id);
6478        let EnvironmentIdentity::Isolated { environment_id, .. } = &stranger else {
6479            unreachable!()
6480        };
6481        terminal.begin_prepare(harness.clock.now()).unwrap();
6482        terminal
6483            .prepared_environment(environment_id.clone())
6484            .unwrap();
6485        terminal.mark_prepared(harness.clock.now()).unwrap();
6486        terminal.jit_received(harness.clock.now()).unwrap();
6487        terminal.started_isolated(harness.clock.now()).unwrap();
6488        terminal
6489            .conclude(
6490                AttemptOutcome::failed(FailureReason::ProcessStartFailed),
6491                harness.clock.now(),
6492            )
6493            .unwrap();
6494        harness.store.record_attempt(&terminal).unwrap();
6495        let EnvironmentIdentity::Isolated { generation, .. } = &mut stranger else {
6496            unreachable!()
6497        };
6498        *generation = "another-generation".into();
6499        provider
6500            .resources
6501            .lock()
6502            .unwrap()
6503            .insert(second_id, (stranger, EnvironmentState::Running));
6504        restarted
6505            .clean(terminal.id)
6506            .await
6507            .expect_err("ownership mismatch quarantined");
6508        assert_eq!(provider.resource_count(), 1);
6509        assert_eq!(
6510            harness.store.attempt(terminal.id).unwrap().unwrap().state(),
6511            AttemptState::CleanupDeferred
6512        );
6513        assert_eq!(provider.native_calls.load(Ordering::SeqCst), 0);
6514    }
6515
6516    #[tokio::test]
6517    async fn isolated_restart_at_each_transition_adopts_or_destroys_one_resource() {
6518        for stage in [
6519            AttemptState::Allocated,
6520            AttemptState::Preparing,
6521            AttemptState::Prepared,
6522            AttemptState::JitReceived,
6523            AttemptState::Starting,
6524            AttemptState::Idle,
6525            AttemptState::Busy,
6526            AttemptState::Finished,
6527            AttemptState::Failed,
6528            AttemptState::Orphaned,
6529            AttemptState::Destroying,
6530            AttemptState::CleanupDeferred,
6531        ] {
6532            let harness = isolated_harness();
6533            let provider = Arc::new(FakeIsolatedProvider::default());
6534            let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
6535            let id = AttemptId::from_u128(0x1000 + stage as u128);
6536            let runtime = harness.host_root().join(format!("phase-{stage}"));
6537            fs::create_dir_all(&runtime).unwrap();
6538            let mut attempt =
6539                RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
6540            let runner_manager_domain::execution::ExecutionPolicy::Isolated { image, .. } =
6541                harness.policy.execution_policy()
6542            else {
6543                unreachable!()
6544            };
6545            attempt
6546                .allocate_execution(AttemptExecution::Isolated {
6547                    provider_kind: Backend::Oci,
6548                    environment_id: None,
6549                    resolved_image: image.clone(),
6550                    generation: format!("generation-{stage}"),
6551                })
6552                .unwrap();
6553            if stage != AttemptState::Allocated {
6554                attempt.begin_prepare(harness.clock.now()).unwrap();
6555            }
6556            let identity = FakeIsolatedProvider::identity(&attempt, harness.policy.host_id);
6557            if stage != AttemptState::Allocated {
6558                let resource_state = if matches!(
6559                    stage,
6560                    AttemptState::Finished
6561                        | AttemptState::Failed
6562                        | AttemptState::Orphaned
6563                        | AttemptState::Destroying
6564                        | AttemptState::CleanupDeferred
6565                ) {
6566                    EnvironmentState::Exited
6567                } else {
6568                    EnvironmentState::Running
6569                };
6570                provider
6571                    .resources
6572                    .lock()
6573                    .unwrap()
6574                    .insert(id, (identity.clone(), resource_state));
6575            }
6576            if !matches!(stage, AttemptState::Allocated | AttemptState::Preparing) {
6577                let EnvironmentIdentity::Isolated { environment_id, .. } = &identity else {
6578                    unreachable!()
6579                };
6580                attempt
6581                    .prepared_environment(environment_id.clone())
6582                    .unwrap();
6583                attempt.mark_prepared(harness.clock.now()).unwrap();
6584            }
6585            if matches!(
6586                stage,
6587                AttemptState::JitReceived
6588                    | AttemptState::Starting
6589                    | AttemptState::Idle
6590                    | AttemptState::Busy
6591                    | AttemptState::Finished
6592                    | AttemptState::Failed
6593                    | AttemptState::Orphaned
6594                    | AttemptState::Destroying
6595                    | AttemptState::CleanupDeferred
6596            ) {
6597                attempt.jit_received(harness.clock.now()).unwrap();
6598            }
6599            if matches!(
6600                stage,
6601                AttemptState::Starting
6602                    | AttemptState::Idle
6603                    | AttemptState::Busy
6604                    | AttemptState::Finished
6605                    | AttemptState::Failed
6606                    | AttemptState::Orphaned
6607                    | AttemptState::Destroying
6608                    | AttemptState::CleanupDeferred
6609            ) {
6610                attempt.started_isolated(harness.clock.now()).unwrap();
6611            }
6612            if matches!(
6613                stage,
6614                AttemptState::Idle | AttemptState::Busy | AttemptState::Finished
6615            ) {
6616                attempt.registered_idle(73, harness.clock.now()).unwrap();
6617            }
6618            if matches!(stage, AttemptState::Busy | AttemptState::Finished) {
6619                attempt.assigned_job(73, harness.clock.now()).unwrap();
6620            }
6621            if matches!(
6622                stage,
6623                AttemptState::Finished
6624                    | AttemptState::Failed
6625                    | AttemptState::Orphaned
6626                    | AttemptState::Destroying
6627                    | AttemptState::CleanupDeferred
6628            ) {
6629                let outcome = match stage {
6630                    AttemptState::Finished => AttemptOutcome::CompletedJob,
6631                    AttemptState::Orphaned => AttemptOutcome::Orphaned,
6632                    _ => AttemptOutcome::failed(FailureReason::ProcessStartFailed),
6633                };
6634                attempt.conclude(outcome, harness.clock.now()).unwrap();
6635            }
6636            if matches!(
6637                stage,
6638                AttemptState::Destroying | AttemptState::CleanupDeferred
6639            ) {
6640                attempt.begin_destroy(harness.clock.now()).unwrap();
6641            }
6642            if stage == AttemptState::CleanupDeferred {
6643                attempt.defer_cleanup(harness.clock.now()).unwrap();
6644            }
6645            harness.store.record_attempt(&attempt).unwrap();
6646            launcher
6647                .recover_startup(std::slice::from_ref(&harness.policy))
6648                .await
6649                .unwrap_or_else(|error| panic!("restart from {stage}: {error}"));
6650            let resumed = harness.store.attempt(id).unwrap().unwrap();
6651            if matches!(
6652                stage,
6653                AttemptState::Allocated
6654                    | AttemptState::Preparing
6655                    | AttemptState::Prepared
6656                    | AttemptState::Finished
6657                    | AttemptState::Failed
6658                    | AttemptState::Orphaned
6659                    | AttemptState::Destroying
6660                    | AttemptState::CleanupDeferred
6661            ) {
6662                assert_eq!(
6663                    resumed.state(),
6664                    AttemptState::Cleaned,
6665                    "{stage} must retire after restart"
6666                );
6667            } else {
6668                assert!(resumed.counts_against_capacity(), "live {stage} is adopted");
6669                assert_eq!(provider.resource_count(), 1);
6670                provider.resources.lock().unwrap().get_mut(&id).unwrap().1 =
6671                    EnvironmentState::Exited;
6672                launcher.supervise(&harness.policy).await.unwrap();
6673                assert_eq!(
6674                    harness.store.attempt(id).unwrap().unwrap().state(),
6675                    AttemptState::Cleaned
6676                );
6677            }
6678            assert_eq!(
6679                provider.resource_count(),
6680                0,
6681                "{stage} resource cleaned exactly once"
6682            );
6683            let destroyed = provider
6684                .actions
6685                .lock()
6686                .unwrap()
6687                .iter()
6688                .filter(|action| **action == "destroy")
6689                .count();
6690            assert_eq!(
6691                destroyed,
6692                usize::from(stage != AttemptState::Allocated),
6693                "{stage} destroy count"
6694            );
6695            assert_eq!(
6696                harness.github.registrations.load(Ordering::SeqCst),
6697                0,
6698                "restart from {stage} cannot duplicate JIT"
6699            );
6700            assert_eq!(provider.native_calls.load(Ordering::SeqCst), 0);
6701        }
6702    }
6703
6704    #[tokio::test]
6705    async fn duplicate_provider_ownership_is_quarantined_until_unambiguous() {
6706        let harness = isolated_harness();
6707        let provider = Arc::new(FakeIsolatedProvider::default());
6708        let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
6709        let id = AttemptId::from_u128(0x7777);
6710        let runtime = harness.host_root().join("duplicate-provider-ownership");
6711        fs::create_dir_all(&runtime).unwrap();
6712        let mut attempt =
6713            RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
6714        let runner_manager_domain::execution::ExecutionPolicy::Isolated { image, .. } =
6715            harness.policy.execution_policy()
6716        else {
6717            unreachable!()
6718        };
6719        attempt
6720            .allocate_execution(AttemptExecution::Isolated {
6721                provider_kind: Backend::Oci,
6722                environment_id: None,
6723                resolved_image: image.clone(),
6724                generation: "duplicate-generation".into(),
6725            })
6726            .unwrap();
6727        attempt.begin_prepare(harness.clock.now()).unwrap();
6728        harness.store.record_attempt(&attempt).unwrap();
6729        let first = FakeIsolatedProvider::identity(&attempt, harness.policy.host_id);
6730        let mut second = first.clone();
6731        let EnvironmentIdentity::Isolated { environment_id, .. } = &mut second else {
6732            unreachable!()
6733        };
6734        *environment_id = "another-environment".into();
6735        provider
6736            .resources
6737            .lock()
6738            .unwrap()
6739            .insert(id, (first, EnvironmentState::Running));
6740        provider.extra_resources.lock().unwrap().push(second);
6741        launcher
6742            .recover_startup(std::slice::from_ref(&harness.policy))
6743            .await
6744            .expect("duplicate is local");
6745        let deferred = harness.store.attempt(id).unwrap().unwrap();
6746        assert_eq!(deferred.state(), AttemptState::CleanupDeferred);
6747        assert!(deferred.counts_against_capacity());
6748        assert_eq!(provider.resource_count(), 1);
6749        assert_eq!(
6750            provider
6751                .actions
6752                .lock()
6753                .unwrap()
6754                .iter()
6755                .filter(|action| **action == "destroy")
6756                .count(),
6757            0
6758        );
6759        provider.extra_resources.lock().unwrap().clear();
6760        // A tolerated cleanup failure is retried after its backoff delay.
6761        harness.clock.advance_secs(31);
6762        launcher
6763            .recover_startup(std::slice::from_ref(&harness.policy))
6764            .await
6765            .expect("single identity recovered");
6766        assert_eq!(provider.resource_count(), 0);
6767        assert_eq!(
6768            harness.store.attempt(id).unwrap().unwrap().state(),
6769            AttemptState::Cleaned
6770        );
6771    }
6772
6773    #[tokio::test]
6774    async fn provider_resource_without_live_journal_is_surfaced_but_never_deleted() {
6775        let harness = isolated_harness();
6776        let provider = Arc::new(FakeIsolatedProvider::default());
6777        let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
6778        let id = AttemptId::from_u128(0x8888);
6779        let runtime = harness.host_root().join("orphan-provider-resource");
6780        fs::create_dir_all(&runtime).unwrap();
6781        let mut attempt =
6782            RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
6783        let runner_manager_domain::execution::ExecutionPolicy::Isolated { image, .. } =
6784            harness.policy.execution_policy()
6785        else {
6786            unreachable!()
6787        };
6788        attempt
6789            .allocate_execution(AttemptExecution::Isolated {
6790                provider_kind: Backend::Oci,
6791                environment_id: None,
6792                resolved_image: image.clone(),
6793                generation: "orphan-generation".into(),
6794            })
6795            .unwrap();
6796        let identity = FakeIsolatedProvider::identity(&attempt, harness.policy.host_id);
6797        provider
6798            .resources
6799            .lock()
6800            .unwrap()
6801            .insert(id, (identity, EnvironmentState::Running));
6802        launcher
6803            .recover_startup(std::slice::from_ref(&harness.policy))
6804            .await
6805            .expect("orphan cannot block host");
6806        assert_eq!(provider.resource_count(), 1);
6807        assert!(
6808            harness
6809                .events
6810                .events()
6811                .contains(&AttemptEvent::QuarantinedOwnedResource { attempt: id })
6812        );
6813        assert_eq!(
6814            provider
6815                .actions
6816                .lock()
6817                .unwrap()
6818                .iter()
6819                .filter(|action| **action == "destroy")
6820                .count(),
6821            0
6822        );
6823
6824        let EnvironmentIdentity::Isolated { environment_id, .. } = provider
6825            .resources
6826            .lock()
6827            .unwrap()
6828            .get(&id)
6829            .unwrap()
6830            .0
6831            .clone()
6832        else {
6833            unreachable!()
6834        };
6835        attempt.begin_prepare(harness.clock.now()).unwrap();
6836        attempt.prepared_environment(environment_id).unwrap();
6837        attempt.mark_prepared(harness.clock.now()).unwrap();
6838        attempt.jit_received(harness.clock.now()).unwrap();
6839        attempt.started_isolated(harness.clock.now()).unwrap();
6840        attempt
6841            .conclude(
6842                AttemptOutcome::failed(FailureReason::ProcessStartFailed),
6843                harness.clock.now(),
6844            )
6845            .unwrap();
6846        attempt.begin_destroy(harness.clock.now()).unwrap();
6847        attempt.clean_isolated(harness.clock.now()).unwrap();
6848        harness.store.record_attempt(&attempt).unwrap();
6849        launcher
6850            .recover_startup(std::slice::from_ref(&harness.policy))
6851            .await
6852            .expect("cleaned row cannot authorize deletion");
6853        assert_eq!(provider.resource_count(), 1);
6854        assert_eq!(
6855            provider
6856                .actions
6857                .lock()
6858                .unwrap()
6859                .iter()
6860                .filter(|action| **action == "destroy")
6861                .count(),
6862            0
6863        );
6864    }
6865
6866    /// The wiring the three-hour outage needed and did not have.
6867    ///
6868    /// A root that refuses a launch does so before `record_allocation`, so no
6869    /// attempt row carries it, and the daemon's log scrubs the paths out of the
6870    /// sentence. The record this asserts is the only surface left, and
6871    /// `service status` reads it -- so if this wiring is ever dropped, the
6872    /// failure goes back to reading `runner_start_failed reason=other` once per
6873    /// poll and nothing else.
6874    #[tokio::test]
6875    async fn a_root_that_refuses_a_launch_is_recorded_and_cleared_when_one_succeeds() {
6876        use runner_manager_platform::service::{clear_runner_root_refusal, runner_root_refusals};
6877
6878        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
6879            .with_host_runner_root();
6880        harness.ready().await;
6881
6882        // A root under two directories that do not exist: refused by the
6883        // preflight, for a reason that has nothing to do with this platform, so
6884        // the assertion holds on all three.
6885        let unusable = harness
6886            ._root
6887            .path()
6888            .join("absent")
6889            .join("deeper")
6890            .join("runners");
6891        let mut host = harness.host.clone();
6892        host.runner_root_override = Some(
6893            LocalAbsolutePath::new(unusable.to_str().expect("a UTF-8 temporary path"))
6894                .expect("a local absolute host root"),
6895        );
6896        harness.store.put_host(&host).unwrap();
6897
6898        let failure = harness
6899            .launch_result()
6900            .await
6901            .expect_err("a root whose parents are missing cannot hold a runner");
6902        assert!(
6903            matches!(failure.reason, FailureReason::Other(_)),
6904            "{failure:?}"
6905        );
6906
6907        let refusals = runner_root_refusals(&harness.app_paths).expect("readable");
6908        let refusal = refusals
6909            .first()
6910            .expect("the refusal reached the one surface that can hold it");
6911        assert_eq!(refusal.policy, harness.policy.id.to_string());
6912        assert_eq!(refusal.kind, "missing_parents");
6913        assert!(
6914            refusal.root.contains("runners") && refusal.detail.contains("runners"),
6915            "the directory must be named in full: {refusal:?}"
6916        );
6917
6918        // And a root that works clears it, so a host that has been fixed stops
6919        // reporting a fault it no longer has.
6920        harness.store.put_host(&harness.host).unwrap();
6921        harness.launch().await;
6922        assert!(
6923            runner_root_refusals(&harness.app_paths)
6924                .expect("readable")
6925                .is_empty(),
6926            "a successful placement clears that policy's record"
6927        );
6928
6929        clear_runner_root_refusal(&harness.app_paths, &harness.policy.id.to_string())
6930            .expect("cleanup");
6931    }
6932
6933    #[tokio::test]
6934    async fn a_launch_reports_how_long_each_step_took() {
6935        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
6936        harness.ready().await;
6937        let started = harness.launch().await;
6938        let events = harness.events.events();
6939        let launched = events
6940            .iter()
6941            .position(|event| matches!(event, AttemptEvent::Launched { attempt, .. } if *attempt == started.id))
6942            .expect("a successful launch reports its step timings");
6943        let starting = events
6944            .iter()
6945            .position(|event| matches!(event, AttemptEvent::State { attempt, state: AttemptState::Starting } if *attempt == started.id))
6946            .unwrap();
6947        assert!(starting < launched, "timings follow the start: {events:?}");
6948    }
6949
6950    #[tokio::test]
6951    async fn a_failed_launch_still_reports_its_step_timings() {
6952        let harness = Harness::new(
6953            FakeGithubLifecycle::default().fail(true),
6954            Arc::new(PersistentDemand),
6955        );
6956        harness.ready().await;
6957        let failed = harness.launch_after_waiting(Duration::from_secs(90)).await;
6958        assert!(failed.is_err());
6959
6960        let timings = harness
6961            .events
6962            .events()
6963            .into_iter()
6964            .find_map(|event| match event {
6965                AttemptEvent::Launched {
6966                    started: false,
6967                    timings,
6968                    ..
6969                } => Some(timings),
6970                _ => None,
6971            })
6972            .expect("a launch that failed at registration reports its timings");
6973        assert_eq!(timings.lock_wait, Duration::from_secs(90));
6974        assert_eq!(timings.spawn, Duration::ZERO, "spawn was never reached");
6975        assert!(timings.total() >= Duration::from_secs(90));
6976    }
6977
6978    #[cfg(target_os = "macos")]
6979    #[tokio::test]
6980    async fn a_launch_removes_the_seed_of_a_root_no_longer_in_use() {
6981        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
6982        harness.ready().await;
6983
6984        // A runtime this host once placed under a root it no longer uses.
6985        let old_root = harness._root.path().join("old-root");
6986        let old_runtime = old_root.join("abandoned");
6987        fs::create_dir_all(old_root.join(PACKAGE_SEED_DIR).join("2.335.0")).unwrap();
6988        fs::create_dir_all(&old_runtime).unwrap();
6989        let old = RunnerAttempt::allocate(
6990            AttemptId::new_random(),
6991            harness.policy.id,
6992            &old_runtime,
6993            harness.clock.now(),
6994        );
6995        harness.store.record_attempt(&old).unwrap();
6996        let kept = harness.host_root().join(PACKAGE_SEED_DIR);
6997        fs::create_dir_all(kept.join("2.336.0")).unwrap();
6998
6999        harness.launch().await;
7000
7001        assert!(
7002            !old_root.join(PACKAGE_SEED_DIR).exists(),
7003            "the abandoned root's seed is removed"
7004        );
7005        assert!(old_runtime.exists(), "only the seed is touched");
7006        assert!(kept.exists(), "the host root's own seed is kept");
7007    }
7008
7009    #[tokio::test]
7010    async fn a_job_walks_every_state_and_cleans_every_artifact() {
7011        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7012        harness.ready().await;
7013        let started = harness.launch().await;
7014        assert_eq!(started.state(), AttemptState::Starting);
7015        assert_eq!(read_runner_id(started.runtime_path()), Some(73));
7016
7017        harness
7018            .github
7019            .observe(GithubRunnerObservation::Registered { busy: false });
7020        harness.launcher.supervise(&harness.policy).await.unwrap();
7021        assert_eq!(harness.only_attempt().state(), AttemptState::Idle);
7022
7023        harness
7024            .github
7025            .observe(GithubRunnerObservation::Registered { busy: true });
7026        harness.launcher.supervise(&harness.policy).await.unwrap();
7027        assert_eq!(harness.only_attempt().state(), AttemptState::Busy);
7028
7029        harness.processes.finish_successfully();
7030        harness
7031            .github
7032            .observe(GithubRunnerObservation::NotRegistered);
7033        harness.launcher.supervise(&harness.policy).await.unwrap();
7034        let cleaned = harness.only_attempt();
7035        assert_eq!(cleaned.state(), AttemptState::Cleaned);
7036        assert_eq!(cleaned.outcome(), Some(&AttemptOutcome::CompletedJob));
7037        assert!(!started.runtime_path().exists());
7038        assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 1);
7039        assert_eq!(harness.github.remaining_runners.load(Ordering::SeqCst), 0);
7040
7041        let states: Vec<_> = harness
7042            .events
7043            .events()
7044            .into_iter()
7045            .filter_map(|event| match event {
7046                AttemptEvent::State { state, .. } => Some(state),
7047                _ => None,
7048            })
7049            .collect();
7050        assert_eq!(
7051            states,
7052            vec![
7053                AttemptState::Allocated,
7054                AttemptState::JitReceived,
7055                AttemptState::Starting,
7056                AttemptState::Idle,
7057                AttemptState::Busy,
7058                AttemptState::Finished,
7059                AttemptState::Cleaned,
7060            ]
7061        );
7062    }
7063
7064    #[tokio::test]
7065    async fn a_cleaned_ephemeral_attempt_reaps_a_directory_recreated_after_cleanup() {
7066        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7067        harness.ready().await;
7068        let started = harness.launch().await;
7069        let runtime = started.runtime_path().to_path_buf();
7070
7071        harness
7072            .github
7073            .observe(GithubRunnerObservation::Registered { busy: true });
7074        harness.launcher.supervise(&harness.policy).await.unwrap();
7075        harness.processes.finish_successfully();
7076        harness
7077            .github
7078            .observe(GithubRunnerObservation::NotRegistered);
7079        harness.launcher.supervise(&harness.policy).await.unwrap();
7080
7081        assert_eq!(harness.attempt(started.id).state(), AttemptState::Cleaned);
7082        assert!(!runtime.exists());
7083        assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 1);
7084
7085        // Reproduce a late orphan creating output after the successful removal
7086        // and journal transition. The next ordinary policy supervision must
7087        // remove it without replaying package release or another state change.
7088        let residue = runtime.join("_work").join("late-node-process");
7089        fs::create_dir_all(&residue).unwrap();
7090        fs::write(residue.join("node_modules.lock"), b"late residue").unwrap();
7091
7092        harness.launcher.supervise(&harness.policy).await.unwrap();
7093
7094        assert!(!runtime.exists(), "late ephemeral residue is reaped");
7095        assert_eq!(harness.attempt(started.id).state(), AttemptState::Cleaned);
7096        assert_eq!(
7097            harness.packages.releases.load(Ordering::SeqCst),
7098            1,
7099            "reaping residue does not release the package lease twice"
7100        );
7101    }
7102
7103    #[tokio::test]
7104    async fn locked_late_residue_never_blocks_startup_recovery() {
7105        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7106        harness.ready().await;
7107        let started = harness.launch().await;
7108        let runtime = started.runtime_path().to_path_buf();
7109
7110        harness
7111            .github
7112            .observe(GithubRunnerObservation::Registered { busy: true });
7113        harness.launcher.supervise(&harness.policy).await.unwrap();
7114        harness.processes.finish_successfully();
7115        harness
7116            .github
7117            .observe(GithubRunnerObservation::NotRegistered);
7118        harness.launcher.supervise(&harness.policy).await.unwrap();
7119        assert_eq!(harness.attempt(started.id).state(), AttemptState::Cleaned);
7120
7121        let held = runtime.join("_work").join("late-node-process");
7122        fs::create_dir_all(&held).unwrap();
7123        fs::write(held.join("node_modules.lock"), b"late residue").unwrap();
7124        let Some(block) = BlockedDeletion::inject(&held) else {
7125            eprintln!(
7126                "skipped: this account cannot be refused a deletion, so locked late residue cannot be injected"
7127            );
7128            return;
7129        };
7130
7131        let restarted = harness.restart();
7132        restarted
7133            .recover_startup(std::slice::from_ref(&harness.policy))
7134            .await
7135            .expect("late residue cannot take the daemon offline");
7136        assert!(runtime.exists(), "the locked residue remains for a retry");
7137        assert!(
7138            harness
7139                .reconcile_events
7140                .events()
7141                .iter()
7142                .any(|event| matches!(
7143                    event,
7144                    LifecycleEvent::AttemptCleanFailed {
7145                        attempt,
7146                        reason: "late_ephemeral_workspace_could_not_be_removed",
7147                        ..
7148                    } if *attempt == started.id
7149                )),
7150            "the non-blocking cleanup failure remains visible"
7151        );
7152
7153        block.release();
7154        // A tolerated cleanup failure is retried after its backoff delay.
7155        harness.clock.advance_secs(31);
7156        restarted
7157            .supervise(&harness.policy)
7158            .await
7159            .expect("the ordinary retry succeeds after the lock is released");
7160        assert!(!runtime.exists(), "the retry removes the late residue");
7161        assert_eq!(harness.attempt(started.id).state(), AttemptState::Cleaned);
7162        assert_eq!(
7163            harness.packages.releases.load(Ordering::SeqCst),
7164            1,
7165            "retrying residue never releases the package lease twice"
7166        );
7167    }
7168
7169    #[tokio::test]
7170    async fn mixed_locked_cleanup_residue_isolated_from_the_whole_startup_pass() {
7171        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7172        harness.ready().await;
7173
7174        // The first attempt is terminal but its initial cleanup cannot finish.
7175        // This is the state left by the original failed job on the affected
7176        // workstation.
7177        let terminal = harness.launch().await;
7178        let terminal_runtime = terminal.runtime_path().to_path_buf();
7179        harness.conclude(terminal.id);
7180        let Some(terminal_block) = BlockedDeletion::inject(&terminal_runtime.join("late-child"))
7181        else {
7182            eprintln!("skipped: this account cannot inject the two independent deletion refusals");
7183            return;
7184        };
7185
7186        // The second attempt was cleaned successfully, then a child recreated
7187        // part of `_work`. Both real-world residue shapes existed together in
7188        // the incident, and neither may short-circuit recovery of the other.
7189        let cleaned = harness.launch().await;
7190        let cleaned_runtime = cleaned.runtime_path().to_path_buf();
7191        harness.conclude(cleaned.id);
7192        harness
7193            .launcher
7194            .clean(cleaned.id)
7195            .await
7196            .expect("the second attempt initially cleans");
7197        let Some(cleaned_block) =
7198            BlockedDeletion::inject(&cleaned_runtime.join("_work").join("late-child"))
7199        else {
7200            terminal_block.release();
7201            eprintln!("skipped: this account cannot inject the two independent deletion refusals");
7202            return;
7203        };
7204
7205        let restarted = harness.restart();
7206        for _ in 0..2 {
7207            restarted
7208                .recover_startup(std::slice::from_ref(&harness.policy))
7209                .await
7210                .expect("repeated recovery remains ready while both residues are locked");
7211        }
7212        assert_ne!(
7213            harness.attempt(terminal.id).state(),
7214            AttemptState::Cleaned,
7215            "the journal does not claim the terminal workspace was removed"
7216        );
7217        assert_eq!(
7218            harness.attempt(cleaned.id).state(),
7219            AttemptState::Cleaned,
7220            "late residue does not undo an already durable cleaned transition"
7221        );
7222        assert!(terminal_runtime.exists());
7223        assert!(cleaned_runtime.exists());
7224        assert_eq!(
7225            harness.packages.releases.load(Ordering::SeqCst),
7226            1,
7227            "repeated recovery neither loses nor duplicates package leases"
7228        );
7229
7230        terminal_block.release();
7231        cleaned_block.release();
7232        // A tolerated cleanup failure is retried after its backoff delay.
7233        harness.clock.advance_secs(31);
7234        restarted
7235            .supervise(&harness.policy)
7236            .await
7237            .expect("one ordinary pass clears both residues after their locks disappear");
7238        assert!(!terminal_runtime.exists());
7239        assert!(!cleaned_runtime.exists());
7240        assert_eq!(harness.attempt(terminal.id).state(), AttemptState::Cleaned);
7241        assert_eq!(harness.attempt(cleaned.id).state(), AttemptState::Cleaned);
7242        assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 2);
7243    }
7244
7245    #[tokio::test]
7246    async fn a_terminal_attempt_whose_runtime_is_already_gone_recovers_cleanly() {
7247        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7248        harness.ready().await;
7249        let attempt = harness.launch().await;
7250        let runtime = attempt.runtime_path().to_path_buf();
7251        harness.conclude(attempt.id);
7252        fs::remove_dir_all(&runtime).unwrap();
7253
7254        harness
7255            .restart()
7256            .recover_startup(std::slice::from_ref(&harness.policy))
7257            .await
7258            .expect("an already absent disposable runtime is successful cleanup");
7259
7260        assert_eq!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
7261        assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 1);
7262    }
7263
7264    #[tokio::test]
7265    async fn a_non_workspace_cleanup_failure_still_fails_closed_and_can_retry() {
7266        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7267        harness.ready().await;
7268        let attempt = harness.launch().await;
7269        let runtime = attempt.runtime_path().to_path_buf();
7270        harness.conclude(attempt.id);
7271        harness.packages.fail_releases(1);
7272
7273        let restarted = harness.restart();
7274        let failure = restarted
7275            .recover_startup(std::slice::from_ref(&harness.policy))
7276            .await
7277            .expect_err("a package-accounting failure is not safe to downgrade to residue");
7278        assert!(
7279            failure
7280                .reason()
7281                .to_string()
7282                .contains("package lease could not be released")
7283        );
7284        assert!(
7285            !runtime.exists(),
7286            "workspace removal completed before release failed"
7287        );
7288        assert_ne!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
7289
7290        restarted
7291            .recover_startup(std::slice::from_ref(&harness.policy))
7292            .await
7293            .expect("the same journal entry retries safely after the transient failure");
7294        assert_eq!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
7295        assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 1);
7296    }
7297
7298    #[tokio::test]
7299    async fn startup_never_scrubs_a_cleaned_persistent_work_directory() {
7300        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
7301            .with_persistent_workspace(1);
7302        harness.ready().await;
7303        let attempt = harness.launch().await;
7304        let retained = attempt.runtime_path().join("_work").join("checkout-marker");
7305        fs::create_dir_all(retained.parent().unwrap()).unwrap();
7306        fs::write(&retained, b"persistent checkout").unwrap();
7307        harness.cleanup_retaining_work(attempt.id).await;
7308
7309        harness
7310            .restart()
7311            .recover_startup(std::slice::from_ref(&harness.policy))
7312            .await
7313            .expect("a cleaned persistent slot is already reconciled");
7314
7315        assert_eq!(fs::read(&retained).unwrap(), b"persistent checkout");
7316        assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 1);
7317    }
7318
7319    #[tokio::test]
7320    async fn an_idle_exit_is_not_a_failure_in_the_journal_or_events() {
7321        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7322        harness.ready().await;
7323        let started = harness.launch().await;
7324        harness
7325            .github
7326            .observe(GithubRunnerObservation::Registered { busy: false });
7327        harness.launcher.supervise(&harness.policy).await.unwrap();
7328        harness.clock.advance_secs(11);
7329        harness.processes.set_alive(false);
7330        harness
7331            .github
7332            .observe(GithubRunnerObservation::NotRegistered);
7333        harness.launcher.supervise(&harness.policy).await.unwrap();
7334
7335        let cleaned = harness.only_attempt();
7336        assert!(cleaned.outcome().unwrap().is_idle_exit());
7337        assert!(!cleaned.outcome().unwrap().is_failure());
7338        assert!(!started.runtime_path().exists());
7339        assert!(
7340            harness
7341                .reconcile_events
7342                .events()
7343                .iter()
7344                .any(|event| matches!(
7345                    event,
7346                    LifecycleEvent::AttemptCleaned {
7347                        outcome: OutcomeKind::IdleExit,
7348                        ..
7349                    }
7350                ))
7351        );
7352        assert!(!harness.events.events().iter().any(|event| matches!(
7353            event,
7354            AttemptEvent::Concluded {
7355                outcome: OutcomeKind::Failed,
7356                ..
7357            }
7358        )));
7359    }
7360
7361    #[tokio::test]
7362    async fn handoff_is_absent_after_success_and_every_failed_spawn_retry() {
7363        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7364        harness.processes.fail_spawns(2);
7365        harness.ready().await;
7366        let attempt = harness.launch().await;
7367        assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 3);
7368        assert!(harness.processes.saw_secret.load(Ordering::SeqCst));
7369        let names: Vec<_> = fs::read_dir(attempt.runtime_path())
7370            .unwrap()
7371            .map(|entry| entry.unwrap().file_name())
7372            .collect();
7373        assert!(
7374            names.iter().all(|name| {
7375                !name
7376                    .to_string_lossy()
7377                    .starts_with(RestrictiveHandoff::NAME_PREFIX)
7378            }),
7379            "JIT artifact survived: {names:?}"
7380        );
7381        assert_eq!(
7382            *harness.delay.0.lock().unwrap(),
7383            vec![Duration::from_millis(10), Duration::from_millis(20)]
7384        );
7385    }
7386
7387    #[tokio::test]
7388    async fn jit_retry_stops_with_demand_and_a_terminal_403_never_retries() {
7389        let gone = Harness::new(
7390            FakeGithubLifecycle::default().fail(false),
7391            Arc::new(FakeDemand::answering([false])),
7392        );
7393        gone.ready().await;
7394        assert!(gone.launch_result().await.is_err());
7395        assert_eq!(gone.github.registrations.load(Ordering::SeqCst), 1);
7396        assert!(gone.delay.0.lock().unwrap().is_empty());
7397
7398        let forbidden = Harness::new(
7399            FakeGithubLifecycle::default().fail(true),
7400            Arc::new(PersistentDemand),
7401        );
7402        forbidden.ready().await;
7403        assert!(forbidden.launch_result().await.is_err());
7404        assert_eq!(forbidden.github.registrations.load(Ordering::SeqCst), 1);
7405        assert!(forbidden.delay.0.lock().unwrap().is_empty());
7406        assert!(matches!(
7407            forbidden.only_attempt().outcome(),
7408            Some(AttemptOutcome::Failed {
7409                reason: FailureReason::Other(action)
7410            }) if action.contains("403")
7411        ));
7412
7413        let transient = Harness::new(
7414            FakeGithubLifecycle::default().fail(false).fail(false),
7415            Arc::new(PersistentDemand),
7416        );
7417        transient.ready().await;
7418        transient.launch().await;
7419        assert_eq!(transient.github.registrations.load(Ordering::SeqCst), 3);
7420        assert_eq!(
7421            *transient.delay.0.lock().unwrap(),
7422            vec![Duration::from_millis(10), Duration::from_millis(20)]
7423        );
7424    }
7425
7426    /// The layout has to leave room for what the runner writes underneath it.
7427    ///
7428    /// Windows refuses a path over `MAX_PATH`, and this product's own CI hit
7429    /// that: 264 characters against a limit of 260, failing three checkout
7430    /// retries with `Filename too long`. The two identifiers in the old layout
7431    /// cost 74 characters between them for no benefit -- an attempt id is
7432    /// unique on its own.
7433    #[test]
7434    fn a_workspace_leaves_room_for_the_deepest_path_a_checkout_writes() {
7435        const MAX_PATH: usize = 260;
7436        // The real root on the machine this was found on.
7437        let root = r"C:\Users\IvanD\AppData\Local\IvanMurzak\runner-manager\data\runtime";
7438        // What `actions/checkout` writes at its deepest: the work directory,
7439        // the repository named twice, and a pack keep-file with a 40-character
7440        // object name.
7441        let repo = "GitHub-Runner-Scaler-UI";
7442        let deepest = format!(
7443            r"_work\{repo}\{repo}\.git\objects\pack\pack-{}.keep",
7444            "0".repeat(40)
7445        );
7446
7447        let name = workspace_name(AttemptId::new_random());
7448        assert_eq!(name.len(), WORKSPACE_NAME_LEN, "{name}");
7449        assert!(
7450            name.chars().all(|c| c.is_ascii_hexdigit()),
7451            "a directory name must not carry the identifier's dashes: {name}"
7452        );
7453
7454        let full = format!(r"{root}\{name}\{deepest}");
7455        assert!(
7456            full.len() < MAX_PATH,
7457            "the deepest path a checkout writes must fit: {} characters, limit {MAX_PATH}",
7458            full.len()
7459        );
7460
7461        // The discriminator: the layout this replaced does not fit, so a test
7462        // that passed for both would be proving nothing.
7463        let old = format!(
7464            r"{root}\{}\{}\{deepest}",
7465            PolicyId::new_random(),
7466            AttemptId::new_random()
7467        );
7468        assert!(
7469            old.len() > MAX_PATH,
7470            "the old layout is supposed to be the thing that did not fit: {} characters",
7471            old.len()
7472        );
7473    }
7474
7475    #[tokio::test]
7476    async fn two_attempts_never_share_a_workspace_even_after_failure() {
7477        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7478        harness.ready().await;
7479        let first = harness.launch().await;
7480        fs::write(first.runtime_path().join("hostile-leftover"), b"first job").unwrap();
7481        harness
7482            .github
7483            .observe(GithubRunnerObservation::Registered { busy: false });
7484        harness.launcher.supervise(&harness.policy).await.unwrap();
7485        harness.clock.advance_secs(11);
7486        harness.processes.set_alive(false);
7487        harness
7488            .github
7489            .observe(GithubRunnerObservation::NotRegistered);
7490        harness.launcher.supervise(&harness.policy).await.unwrap();
7491        assert!(!first.runtime_path().exists());
7492
7493        let second = harness.launch().await;
7494        assert_ne!(first.runtime_path(), second.runtime_path());
7495        assert!(!second.runtime_path().join("hostile-leftover").exists());
7496
7497        fs::write(
7498            second.runtime_path().join("hostile-on-failure"),
7499            b"second job",
7500        )
7501        .unwrap();
7502        harness.processes.set_alive(false);
7503        harness
7504            .github
7505            .observe(GithubRunnerObservation::NotRegistered);
7506        harness.launcher.supervise(&harness.policy).await.unwrap();
7507        assert!(
7508            !second.runtime_path().exists(),
7509            "failed workspace was retained"
7510        );
7511    }
7512
7513    #[tokio::test]
7514    async fn a_runner_that_never_gets_a_job_is_stopped_deregistered_and_not_replaced() {
7515        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7516        harness.ready().await;
7517        let attempt = harness.launch().await;
7518
7519        // Registered and waiting, which is where it stays: the fake keeps
7520        // answering the same observation, exactly as GitHub does for a runner
7521        // nobody assigns work to.
7522        harness
7523            .github
7524            .observe(GithubRunnerObservation::Registered { busy: false });
7525        harness.launcher.supervise(&harness.policy).await.unwrap();
7526        assert_eq!(harness.only_attempt().state(), AttemptState::Idle);
7527
7528        // One second inside the ten-second idle timeout nothing happens, which
7529        // is what keeps this from being a test that would pass on any clock.
7530        harness.clock.advance_secs(9);
7531        harness
7532            .github
7533            .observe(GithubRunnerObservation::Registered { busy: false });
7534        let none_yet = harness.launcher.supervise(&harness.policy).await.unwrap();
7535        assert_eq!(harness.only_attempt().state(), AttemptState::Idle);
7536        assert!(none_yet.is_empty());
7537        assert_eq!(harness.processes.terminations.load(Ordering::SeqCst), 0);
7538
7539        // Past it, the agent ends the runner itself.
7540        harness.clock.advance_secs(1);
7541        harness
7542            .github
7543            .observe(GithubRunnerObservation::Registered { busy: false });
7544        let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
7545
7546        let concluded = harness.store.attempt(attempt.id).unwrap().unwrap();
7547        assert_eq!(
7548            concluded.outcome(),
7549            Some(&AttemptOutcome::ExitedIdleWithoutWork),
7550            "a surplus runner did not fail; recording one as a failure sends an operator \
7551             hunting a fault that does not exist"
7552        );
7553        assert_eq!(concluded.state(), AttemptState::Cleaned);
7554        assert_eq!(harness.processes.terminations.load(Ordering::SeqCst), 1);
7555        assert!(!attempt.runtime_path().exists());
7556
7557        // The registration goes with it. Without this the runner stays listed
7558        // in the target's runner settings after the process it named is gone.
7559        assert_eq!(
7560            *harness.github.deregistrations.lock().unwrap(),
7561            vec![73],
7562            "the attempt's own runner id, deleted exactly once"
7563        );
7564
7565        // And nothing is started in its place: the work it was launched for went
7566        // elsewhere, so a replacement would rebuild it every idle timeout.
7567        assert!(
7568            replacements.is_empty(),
7569            "a surplus exit must not request a replacement"
7570        );
7571    }
7572
7573    #[tokio::test]
7574    async fn a_registration_github_will_not_delete_still_concludes_the_attempt() {
7575        // The delete is best-effort by construction: the process is gone and the
7576        // slot has to come back. Holding the conclusion until GitHub cooperates
7577        // would leak a capacity slot on every unreachable moment.
7578        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7579        harness.ready().await;
7580        let attempt = harness.launch().await;
7581        harness
7582            .github
7583            .observe(GithubRunnerObservation::Registered { busy: false });
7584        harness.launcher.supervise(&harness.policy).await.unwrap();
7585
7586        harness
7587            .github
7588            .deregistration_fails
7589            .store(true, Ordering::SeqCst);
7590        harness.clock.advance_secs(11);
7591        harness
7592            .github
7593            .observe(GithubRunnerObservation::Registered { busy: false });
7594        harness.launcher.supervise(&harness.policy).await.unwrap();
7595
7596        assert_eq!(
7597            *harness.github.deregistrations.lock().unwrap(),
7598            vec![73],
7599            "the delete was attempted"
7600        );
7601        let concluded = harness.store.attempt(attempt.id).unwrap().unwrap();
7602        assert_eq!(
7603            concluded.outcome(),
7604            Some(&AttemptOutcome::ExitedIdleWithoutWork),
7605            "the attempt concluded anyway"
7606        );
7607        assert_eq!(concluded.state(), AttemptState::Cleaned);
7608        assert!(!attempt.runtime_path().exists());
7609    }
7610
7611    #[tokio::test]
7612    async fn exit_before_acceptance_returns_replacement_intent_without_launching() {
7613        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7614        harness.ready().await;
7615        let first = harness.launch().await;
7616        harness.processes.set_alive(false);
7617        harness
7618            .github
7619            .observe(GithubRunnerObservation::NotRegistered);
7620        let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
7621        let failed = harness.store.attempt(first.id).unwrap().unwrap();
7622        assert!(matches!(
7623            failed.outcome(),
7624            Some(AttemptOutcome::Failed {
7625                reason: FailureReason::ProcessExitedUnexpectedly
7626            })
7627        ));
7628        assert!(!first.runtime_path().exists());
7629
7630        assert_eq!(
7631            replacements,
7632            vec![ReplacementIntent {
7633                policy: harness.policy.id,
7634                previous_attempt: first.id,
7635                operation: "exit_before_acceptance_replacement",
7636            }]
7637        );
7638        assert_eq!(harness.store.attempts().unwrap().len(), 1);
7639        assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 1);
7640        assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 1);
7641        assert!(harness.delay.0.lock().unwrap().is_empty());
7642    }
7643
7644    /// Writes the runner's own diagnostics: always the listener's log, and the
7645    /// job worker's when `ran_a_job`. `Runner.Listener` starts `Runner.Worker`
7646    /// only for a job it has acquired.
7647    fn write_runner_diagnostics(runtime: &Path, ran_a_job: bool) {
7648        let diag = runtime.join("_diag");
7649        fs::create_dir_all(&diag).unwrap();
7650        fs::write(diag.join("Runner_20261007-211941-utc.log"), b"listener").unwrap();
7651        if ran_a_job {
7652            fs::write(diag.join("Worker_20261007-211950-utc.log"), b"worker").unwrap();
7653        }
7654    }
7655
7656    /// The macOS field case: a job of a few seconds starts and ends between two
7657    /// passes, so GitHub was never seen reporting the runner busy -- the attempt
7658    /// is still `starting` -- and the registration is already gone. It used to be
7659    /// recorded `process_exited_unexpectedly`, with a replacement runner.
7660    #[tokio::test]
7661    async fn a_job_too_short_to_be_seen_busy_is_recorded_as_completed() {
7662        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7663        harness.ready().await;
7664        let started = harness.launch().await;
7665        assert_eq!(started.state(), AttemptState::Starting);
7666        write_runner_diagnostics(started.runtime_path(), true);
7667        harness.processes.finish_successfully();
7668        harness
7669            .github
7670            .observe(GithubRunnerObservation::NotRegistered);
7671
7672        let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
7673
7674        let cleaned = harness.only_attempt();
7675        assert_eq!(cleaned.outcome(), Some(&AttemptOutcome::CompletedJob));
7676        assert_eq!(cleaned.state(), AttemptState::Cleaned);
7677        assert_eq!(cleaned.github_runner_id(), Some(73));
7678        assert!(
7679            replacements.is_empty(),
7680            "a finished job needs no replacement"
7681        );
7682        let busy_seen = harness.events.events().into_iter().any(|event| {
7683            matches!(
7684                event,
7685                AttemptEvent::State {
7686                    state: AttemptState::Busy,
7687                    ..
7688                }
7689            )
7690        });
7691        assert!(busy_seen, "the journal walks the diagram to `busy` first");
7692    }
7693
7694    /// The same from `idle`: seen registered and waiting once, then gone.
7695    #[tokio::test]
7696    async fn a_job_taken_between_two_passes_after_idle_is_recorded_as_completed() {
7697        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7698        harness.ready().await;
7699        let started = harness.launch().await;
7700        harness
7701            .github
7702            .observe(GithubRunnerObservation::Registered { busy: false });
7703        harness.launcher.supervise(&harness.policy).await.unwrap();
7704        assert_eq!(harness.only_attempt().state(), AttemptState::Idle);
7705
7706        write_runner_diagnostics(started.runtime_path(), true);
7707        harness.processes.finish_successfully();
7708        harness
7709            .github
7710            .observe(GithubRunnerObservation::NotRegistered);
7711        let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
7712
7713        assert_eq!(
7714            harness.only_attempt().outcome(),
7715            Some(&AttemptOutcome::CompletedJob)
7716        );
7717        assert!(replacements.is_empty());
7718    }
7719
7720    /// The reverse error, from the same sampling: GitHub showed the runner
7721    /// assigned, the runner never started the job (no worker log), and it exited
7722    /// 0. That is not a completed job.
7723    #[tokio::test]
7724    async fn an_assignment_the_runner_never_took_up_is_not_a_completed_job() {
7725        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7726        harness.ready().await;
7727        let started = harness.launch().await;
7728        harness
7729            .github
7730            .observe(GithubRunnerObservation::Registered { busy: true });
7731        harness.launcher.supervise(&harness.policy).await.unwrap();
7732        assert_eq!(harness.only_attempt().state(), AttemptState::Busy);
7733
7734        write_runner_diagnostics(started.runtime_path(), false);
7735        harness.processes.finish_successfully();
7736        harness
7737            .github
7738            .observe(GithubRunnerObservation::NotRegistered);
7739        harness.launcher.supervise(&harness.policy).await.unwrap();
7740
7741        let cleaned = harness.only_attempt();
7742        assert_eq!(
7743            cleaned.outcome(),
7744            Some(&AttemptOutcome::ExitedIdleWithoutWork)
7745        );
7746        assert_eq!(cleaned.state(), AttemptState::Cleaned);
7747    }
7748
7749    /// The mirror case: the runner really ran its job and exited 0 before GitHub
7750    /// took it off the list. It used to be recorded `orphaned`.
7751    #[tokio::test]
7752    async fn a_finished_job_still_listed_at_github_is_completed_and_deregistered() {
7753        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7754        harness.ready().await;
7755        let started = harness.launch().await;
7756        harness
7757            .github
7758            .observe(GithubRunnerObservation::Registered { busy: true });
7759        harness.launcher.supervise(&harness.policy).await.unwrap();
7760
7761        write_runner_diagnostics(started.runtime_path(), true);
7762        harness.processes.finish_successfully();
7763        harness
7764            .github
7765            .observe(GithubRunnerObservation::Registered { busy: true });
7766        harness.launcher.supervise(&harness.policy).await.unwrap();
7767
7768        assert_eq!(
7769            harness.only_attempt().outcome(),
7770            Some(&AttemptOutcome::CompletedJob)
7771        );
7772        assert_eq!(*harness.github.deregistrations.lock().unwrap(), vec![73]);
7773    }
7774
7775    /// A live runner journalled `busy` whose assignment GitHub withdrew goes
7776    /// back to `idle`, where the idle timeout holds it again.
7777    #[tokio::test]
7778    async fn a_live_runner_whose_assignment_was_withdrawn_goes_back_to_idle() {
7779        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7780        harness.ready().await;
7781        let started = harness.launch().await;
7782        harness
7783            .github
7784            .observe(GithubRunnerObservation::Registered { busy: true });
7785        harness.launcher.supervise(&harness.policy).await.unwrap();
7786        assert_eq!(harness.only_attempt().state(), AttemptState::Busy);
7787
7788        write_runner_diagnostics(started.runtime_path(), false);
7789        harness
7790            .github
7791            .observe(GithubRunnerObservation::Registered { busy: false });
7792        harness.launcher.supervise(&harness.policy).await.unwrap();
7793        assert_eq!(harness.only_attempt().state(), AttemptState::Idle);
7794
7795        // A runner that did start its job keeps `busy` whatever GitHub says.
7796        harness
7797            .github
7798            .observe(GithubRunnerObservation::Registered { busy: true });
7799        harness.launcher.supervise(&harness.policy).await.unwrap();
7800        write_runner_diagnostics(started.runtime_path(), true);
7801        harness
7802            .github
7803            .observe(GithubRunnerObservation::Registered { busy: false });
7804        harness.launcher.supervise(&harness.policy).await.unwrap();
7805        assert_eq!(harness.only_attempt().state(), AttemptState::Busy);
7806    }
7807
7808    /// While GitHub cannot be reached nothing is concluded, so a registration
7809    /// still listed there is removed once it answers.
7810    #[tokio::test]
7811    async fn a_clean_exit_waits_for_github_to_answer() {
7812        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7813        harness.ready().await;
7814        let started = harness.launch().await;
7815        write_runner_diagnostics(started.runtime_path(), true);
7816        harness.processes.finish_successfully();
7817        harness.github.observe(GithubRunnerObservation::Unreachable);
7818        harness.launcher.supervise(&harness.policy).await.unwrap();
7819        assert_eq!(harness.only_attempt().state(), AttemptState::Starting);
7820
7821        harness
7822            .github
7823            .observe(GithubRunnerObservation::NotRegistered);
7824        harness.launcher.supervise(&harness.policy).await.unwrap();
7825        assert_eq!(
7826            harness.only_attempt().outcome(),
7827            Some(&AttemptOutcome::CompletedJob)
7828        );
7829    }
7830
7831    /// A runner id nobody can name is not a reason to stop supervising the
7832    /// policy: the attempt is concluded the way it was before, without one.
7833    #[tokio::test]
7834    async fn a_short_job_with_no_runner_id_still_concludes() {
7835        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7836        harness.ready().await;
7837        let started = harness.launch().await;
7838        fs::remove_file(started.runtime_path().join(RUNNER_ID_FILE)).unwrap();
7839        write_runner_diagnostics(started.runtime_path(), true);
7840        harness.processes.finish_successfully();
7841        harness
7842            .github
7843            .observe(GithubRunnerObservation::NotRegistered);
7844        harness
7845            .launcher
7846            .supervise(&harness.policy)
7847            .await
7848            .expect("supervision goes on");
7849        assert!(harness.only_attempt().is_terminal());
7850    }
7851
7852    /// Without the runner's diagnostics nothing changes: a clean exit seen only
7853    /// at `starting` is still a runner that exited before taking a job.
7854    #[tokio::test]
7855    async fn without_runner_diagnostics_an_early_exit_is_still_a_failure() {
7856        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7857        harness.ready().await;
7858        harness.launch().await;
7859        harness.processes.finish_successfully();
7860        harness
7861            .github
7862            .observe(GithubRunnerObservation::NotRegistered);
7863        harness.launcher.supervise(&harness.policy).await.unwrap();
7864        assert!(matches!(
7865            harness.only_attempt().outcome(),
7866            Some(AttemptOutcome::Failed {
7867                reason: FailureReason::ProcessExitedUnexpectedly
7868            })
7869        ));
7870    }
7871
7872    #[tokio::test]
7873    async fn expired_jit_is_removed_and_does_not_reregister_after_demand_disappears() {
7874        let harness = Harness::new(
7875            FakeGithubLifecycle::default(),
7876            Arc::new(FakeDemand::answering([false])),
7877        );
7878        let id = AttemptId::new_random();
7879        let runtime = harness
7880            .launcher
7881            .app_paths
7882            .runtime_dir()
7883            .join(harness.policy.id.to_string())
7884            .join(id.to_string());
7885        fs::create_dir_all(&runtime).unwrap();
7886        let mut attempt =
7887            RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
7888        attempt.jit_received(harness.clock.now()).unwrap();
7889        harness.store.record_attempt(&attempt).unwrap();
7890        harness.clock.advance_secs(11);
7891        let replacements = harness
7892            .launcher
7893            .recover_startup(std::slice::from_ref(&harness.policy))
7894            .await
7895            .unwrap();
7896        assert_eq!(
7897            replacements,
7898            vec![ReplacementIntent {
7899                policy: harness.policy.id,
7900                previous_attempt: id,
7901                operation: "jit_expired_replacement",
7902            }]
7903        );
7904
7905        let cleaned = harness.store.attempt(id).unwrap().unwrap();
7906        assert_eq!(cleaned.state(), AttemptState::Cleaned);
7907        assert!(matches!(
7908            cleaned.outcome(),
7909            Some(AttemptOutcome::Failed {
7910                reason: FailureReason::JitExpired
7911            })
7912        ));
7913        assert!(!runtime.exists());
7914        assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
7915        assert!(harness.delay.0.lock().unwrap().is_empty());
7916    }
7917
7918    #[tokio::test]
7919    async fn expired_jit_returns_intent_but_never_launches_inside_lifecycle() {
7920        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7921        let id = AttemptId::new_random();
7922        let runtime = harness
7923            .launcher
7924            .app_paths
7925            .runtime_dir()
7926            .join("expired-with-demand");
7927        fs::create_dir_all(&runtime).unwrap();
7928        let mut attempt =
7929            RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
7930        attempt.jit_received(harness.clock.now()).unwrap();
7931        harness.store.record_attempt(&attempt).unwrap();
7932        harness.clock.advance_secs(11);
7933        let replacements = harness
7934            .launcher
7935            .recover_startup(std::slice::from_ref(&harness.policy))
7936            .await
7937            .unwrap();
7938
7939        let attempts = harness.store.attempts().unwrap();
7940        assert_eq!(attempts.len(), 1);
7941        assert_eq!(
7942            attempts
7943                .iter()
7944                .find(|attempt| attempt.id == id)
7945                .unwrap()
7946                .state(),
7947            AttemptState::Cleaned
7948        );
7949        assert_eq!(
7950            replacements,
7951            vec![ReplacementIntent {
7952                policy: harness.policy.id,
7953                previous_attempt: id,
7954                operation: "jit_expired_replacement",
7955            }]
7956        );
7957        assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
7958        assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 0);
7959        assert!(harness.delay.0.lock().unwrap().is_empty());
7960    }
7961
7962    #[tokio::test]
7963    async fn package_materialization_retries_are_bounded_and_demand_adjacent() {
7964        let persistent = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7965        persistent.packages.fail_materializations(2);
7966        persistent.ready().await;
7967        persistent.launch().await;
7968        assert_eq!(
7969            persistent.packages.materializations.load(Ordering::SeqCst),
7970            3
7971        );
7972        assert_eq!(
7973            *persistent.delay.0.lock().unwrap(),
7974            vec![Duration::from_millis(10), Duration::from_millis(20)]
7975        );
7976
7977        let gone_before_wait = Harness::new(
7978            FakeGithubLifecycle::default(),
7979            Arc::new(FakeDemand::answering([false])),
7980        );
7981        gone_before_wait.packages.fail_materializations(3);
7982        gone_before_wait.ready().await;
7983        assert!(gone_before_wait.launch_result().await.is_err());
7984        assert_eq!(
7985            gone_before_wait
7986                .packages
7987                .materializations
7988                .load(Ordering::SeqCst),
7989            1
7990        );
7991        assert!(gone_before_wait.delay.0.lock().unwrap().is_empty());
7992
7993        let gone_during_wait = Harness::new(
7994            FakeGithubLifecycle::default(),
7995            Arc::new(FakeDemand::answering([true, false])),
7996        );
7997        gone_during_wait.packages.fail_materializations(3);
7998        gone_during_wait.ready().await;
7999        assert!(gone_during_wait.launch_result().await.is_err());
8000        assert_eq!(
8001            gone_during_wait
8002                .packages
8003                .materializations
8004                .load(Ordering::SeqCst),
8005            1
8006        );
8007        assert_eq!(
8008            *gone_during_wait.delay.0.lock().unwrap(),
8009            vec![Duration::from_millis(10)]
8010        );
8011    }
8012
8013    #[tokio::test]
8014    async fn replacement_is_intent_only_and_never_launches_inside_lifecycle() {
8015        let harness = Harness::new(
8016            FakeGithubLifecycle::default(),
8017            Arc::new(FakeDemand::answering([true, false])),
8018        );
8019        harness.ready().await;
8020        let first = harness.launch().await;
8021        harness.processes.set_alive(false);
8022        harness
8023            .github
8024            .observe(GithubRunnerObservation::NotRegistered);
8025        let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
8026
8027        assert_eq!(harness.store.attempts().unwrap().len(), 1);
8028        assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 1);
8029        assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 1);
8030        assert!(harness.delay.0.lock().unwrap().is_empty());
8031        assert_eq!(
8032            replacements,
8033            vec![ReplacementIntent {
8034                policy: harness.policy.id,
8035                previous_attempt: first.id,
8036                operation: "exit_before_acceptance_replacement",
8037            }]
8038        );
8039        assert_eq!(
8040            harness.store.attempt(first.id).unwrap().unwrap().state(),
8041            AttemptState::Cleaned
8042        );
8043    }
8044
8045    #[tokio::test]
8046    async fn startup_adopts_a_live_process_and_refuses_launch_before_recovery() {
8047        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8048        let before = harness.launch_result().await;
8049        assert!(before.is_err());
8050        assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 0);
8051
8052        let id = AttemptId::new_random();
8053        let runtime = harness.launcher.app_paths.runtime_dir().join("adopt");
8054        fs::create_dir_all(&runtime).unwrap();
8055        let mut attempt =
8056            RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
8057        attempt.jit_received(harness.clock.now()).unwrap();
8058        attempt.started(4242, harness.clock.now()).unwrap();
8059        harness.store.record_attempt(&attempt).unwrap();
8060        harness.processes.set_alive(true);
8061        harness
8062            .github
8063            .observe(GithubRunnerObservation::NotRegistered);
8064        let replacements = harness
8065            .launcher
8066            .recover_startup(std::slice::from_ref(&harness.policy))
8067            .await
8068            .unwrap();
8069        assert!(replacements.is_empty());
8070        assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 0);
8071        assert!(
8072            harness
8073                .events
8074                .events()
8075                .contains(&AttemptEvent::Adopted { attempt: id })
8076        );
8077    }
8078
8079    #[tokio::test]
8080    async fn spawn_before_starting_crash_recovers_pid_then_completes_and_cleans() {
8081        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8082        let id = AttemptId::new_random();
8083        let runtime = harness
8084            .launcher
8085            .app_paths
8086            .runtime_dir()
8087            .join("spawn-before-starting");
8088        fs::create_dir_all(&runtime).unwrap();
8089        let mut attempt =
8090            RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
8091        attempt.jit_received(harness.clock.now()).unwrap();
8092        harness.store.record_attempt(&attempt).unwrap();
8093        harness.processes.set_alive(true);
8094        harness
8095            .github
8096            .observe(GithubRunnerObservation::Registered { busy: true });
8097
8098        let replacements = harness
8099            .launcher
8100            .recover_startup(std::slice::from_ref(&harness.policy))
8101            .await
8102            .unwrap();
8103        assert!(replacements.is_empty());
8104        let recovered = harness.store.attempt(id).unwrap().unwrap();
8105        assert_eq!(recovered.state(), AttemptState::Busy);
8106        assert_eq!(recovered.process_id(), Some(4242));
8107        assert_eq!(recovered.github_runner_id(), Some(73));
8108        let events = harness.events.events();
8109        let starting = events
8110            .iter()
8111            .position(|event| matches!(event, AttemptEvent::State { attempt, state: AttemptState::Starting } if *attempt == id))
8112            .unwrap();
8113        let busy = events
8114            .iter()
8115            .position(|event| matches!(event, AttemptEvent::State { attempt, state: AttemptState::Busy } if *attempt == id))
8116            .unwrap();
8117        assert!(starting < busy, "recovery skipped a legal edge: {events:?}");
8118
8119        harness.processes.finish_successfully();
8120        harness
8121            .github
8122            .observe(GithubRunnerObservation::NotRegistered);
8123        assert!(
8124            harness
8125                .launcher
8126                .supervise(&harness.policy)
8127                .await
8128                .unwrap()
8129                .is_empty()
8130        );
8131        let cleaned = harness.store.attempt(id).unwrap().unwrap();
8132        assert_eq!(cleaned.state(), AttemptState::Cleaned);
8133        assert_eq!(cleaned.outcome(), Some(&AttemptOutcome::CompletedJob));
8134        assert!(!runtime.exists());
8135    }
8136
8137    #[tokio::test]
8138    async fn failed_post_spawn_stop_keeps_capacity_until_supervision_proves_death() {
8139        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8140        harness.processes.fail_spawn_with_live_child();
8141        harness.ready().await;
8142        assert!(harness.launch_result().await.is_err());
8143
8144        let attempt = harness.only_attempt();
8145        assert_eq!(attempt.state(), AttemptState::Starting);
8146        assert_eq!(attempt.process_id(), Some(4242));
8147        assert!(attempt.outcome().is_none());
8148        assert!(attempt.state().counts_against_capacity());
8149        assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 1);
8150        assert!(harness.delay.0.lock().unwrap().is_empty());
8151
8152        harness.processes.set_alive(false);
8153        harness
8154            .github
8155            .observe(GithubRunnerObservation::NotRegistered);
8156        let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
8157        assert_eq!(replacements.len(), 1);
8158        assert_eq!(
8159            harness.store.attempt(attempt.id).unwrap().unwrap().state(),
8160            AttemptState::Cleaned
8161        );
8162    }
8163
8164    #[tokio::test]
8165    async fn remote_runner_identity_closes_both_sides_of_the_registration_crash_boundary() {
8166        for sidecar_already_present in [false, true] {
8167            let harness = Harness::new(
8168                FakeGithubLifecycle::default(),
8169                Arc::new(FakeDemand::answering([false])),
8170            );
8171            let id = AttemptId::new_random();
8172            let runtime =
8173                harness
8174                    .launcher
8175                    .app_paths
8176                    .runtime_dir()
8177                    .join(if sidecar_already_present {
8178                        "after-id-sidecar"
8179                    } else {
8180                        "before-id-sidecar"
8181                    });
8182            fs::create_dir_all(&runtime).unwrap();
8183            let mut attempt =
8184                RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
8185            if sidecar_already_present {
8186                write_runner_id(&runtime, 73).unwrap();
8187                attempt.jit_received(harness.clock.now()).unwrap();
8188            }
8189            harness.store.record_attempt(&attempt).unwrap();
8190            harness.processes.set_alive(true);
8191            harness
8192                .github
8193                .observe(GithubRunnerObservation::Registered { busy: false });
8194            harness
8195                .launcher
8196                .recover_startup(std::slice::from_ref(&harness.policy))
8197                .await
8198                .unwrap();
8199
8200            assert_eq!(read_runner_id(&runtime), Some(73));
8201            assert!(
8202                harness
8203                    .store
8204                    .attempt(id)
8205                    .unwrap()
8206                    .unwrap()
8207                    .outcome()
8208                    .is_none()
8209            );
8210            let events = harness.events.events();
8211            let recovered = events.iter().position(|event| {
8212                matches!(
8213                    event,
8214                    AttemptEvent::RemoteIdentityRecovered {
8215                        attempt,
8216                        runner_id: 73
8217                    } if *attempt == id
8218                )
8219            });
8220            assert_eq!(recovered.is_some(), !sidecar_already_present);
8221            if let Some(recovered) = recovered {
8222                let adopted = events
8223                    .iter()
8224                    .position(|event| matches!(event, AttemptEvent::Adopted { attempt } if *attempt == id))
8225                    .unwrap();
8226                assert!(
8227                    recovered < adopted,
8228                    "identity was not durable before adoption: {events:?}"
8229                );
8230            }
8231            assert!(runtime.exists());
8232        }
8233    }
8234
8235    #[tokio::test]
8236    async fn recovery_stays_closed_for_unknown_policy_and_unreachable_attempts() {
8237        let unknown = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8238        let unknown_attempt = RunnerAttempt::allocate(
8239            AttemptId::new_random(),
8240            PolicyId::from_u128(0xfeed),
8241            unknown
8242                .launcher
8243                .app_paths
8244                .runtime_dir()
8245                .join("unknown-policy"),
8246            unknown.clock.now(),
8247        );
8248        unknown.store.record_attempt(&unknown_attempt).unwrap();
8249        let expired_id = AttemptId::new_random();
8250        let expired_runtime = unknown
8251            .launcher
8252            .app_paths
8253            .runtime_dir()
8254            .join("expired-beside-unknown");
8255        fs::create_dir_all(&expired_runtime).unwrap();
8256        let mut expired = RunnerAttempt::allocate(
8257            expired_id,
8258            unknown.policy.id,
8259            expired_runtime,
8260            unknown.clock.now(),
8261        );
8262        expired.jit_received(unknown.clock.now()).unwrap();
8263        unknown.store.record_attempt(&expired).unwrap();
8264        unknown.clock.advance_secs(11);
8265        assert!(matches!(
8266            unknown
8267                .launcher
8268                .recover_startup(std::slice::from_ref(&unknown.policy))
8269                .await,
8270            Err(LifecycleError::RecoveryIncomplete)
8271        ));
8272        assert!(unknown.launch_result().await.is_err());
8273        assert_eq!(unknown.processes.spawns.load(Ordering::SeqCst), 0);
8274        let recovered_policy = fixtures::policy()
8275            .id(PolicyId::from_u128(0xfeed))
8276            .repository("octo/repo")
8277            .autoscale("home", 2)
8278            .active()
8279            .build();
8280        let pending = unknown
8281            .launcher
8282            .recover_startup(&[unknown.policy.clone(), recovered_policy])
8283            .await
8284            .unwrap();
8285        assert_eq!(
8286            pending,
8287            vec![ReplacementIntent {
8288                policy: unknown.policy.id,
8289                previous_attempt: expired_id,
8290                operation: "jit_expired_replacement",
8291            }]
8292        );
8293        assert_eq!(unknown.processes.spawns.load(Ordering::SeqCst), 0);
8294
8295        let unreachable = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8296        let id = AttemptId::new_random();
8297        let runtime = unreachable
8298            .launcher
8299            .app_paths
8300            .runtime_dir()
8301            .join("unreachable");
8302        fs::create_dir_all(&runtime).unwrap();
8303        unreachable
8304            .store
8305            .record_attempt(&RunnerAttempt::allocate(
8306                id,
8307                unreachable.policy.id,
8308                runtime,
8309                unreachable.clock.now(),
8310            ))
8311            .unwrap();
8312        unreachable
8313            .github
8314            .observe(GithubRunnerObservation::Unreachable);
8315        assert!(matches!(
8316            unreachable
8317                .launcher
8318                .recover_startup(std::slice::from_ref(&unreachable.policy))
8319                .await,
8320            Err(LifecycleError::RecoveryIncomplete)
8321        ));
8322        assert!(unreachable.launch_result().await.is_err());
8323        assert_eq!(unreachable.processes.spawns.load(Ordering::SeqCst), 0);
8324    }
8325
8326    #[tokio::test]
8327    async fn a_dead_busy_process_unknown_to_github_is_orphaned_and_cleaned() {
8328        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8329        let id = AttemptId::new_random();
8330        let runtime = harness.launcher.app_paths.runtime_dir().join("orphan");
8331        fs::create_dir_all(&runtime).unwrap();
8332        let mut attempt =
8333            RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
8334        attempt.jit_received(harness.clock.now()).unwrap();
8335        attempt.started(4242, harness.clock.now()).unwrap();
8336        attempt.assigned_job(73, harness.clock.now()).unwrap();
8337        harness.store.record_attempt(&attempt).unwrap();
8338        harness.processes.set_alive(false);
8339        harness
8340            .github
8341            .observe(GithubRunnerObservation::NotRegistered);
8342        harness
8343            .launcher
8344            .recover_startup(std::slice::from_ref(&harness.policy))
8345            .await
8346            .unwrap();
8347        let cleaned = harness.store.attempt(id).unwrap().unwrap();
8348        assert_eq!(cleaned.state(), AttemptState::Cleaned);
8349        assert_eq!(cleaned.outcome(), Some(&AttemptOutcome::Orphaned));
8350        assert!(!runtime.exists());
8351    }
8352
8353    #[tokio::test]
8354    async fn registration_timeout_journals_intent_stops_then_concludes_with_dead_reason() {
8355        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8356        harness.ready().await;
8357        let id = AttemptId::new_random();
8358        let runtime = harness.launcher.app_paths.runtime_dir().join("timeout");
8359        fs::create_dir_all(&runtime).unwrap();
8360        let mut attempt =
8361            RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
8362        attempt.jit_received(harness.clock.now()).unwrap();
8363        attempt.started(4242, harness.clock.now()).unwrap();
8364        harness.store.record_attempt(&attempt).unwrap();
8365        harness.clock.advance_secs(11);
8366        harness.processes.set_alive(true);
8367        harness
8368            .github
8369            .observe(GithubRunnerObservation::NotRegistered);
8370        let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
8371        assert_eq!(
8372            replacements,
8373            vec![ReplacementIntent {
8374                policy: harness.policy.id,
8375                previous_attempt: id,
8376                operation: "registration_timeout_replacement",
8377            }]
8378        );
8379
8380        assert_eq!(harness.processes.terminations.load(Ordering::SeqCst), 1);
8381        assert!(!harness.processes.alive.load(Ordering::SeqCst));
8382        let actions = harness.processes.actions.lock().unwrap().clone();
8383        let intent = actions
8384            .iter()
8385            .position(|action| *action == "terminate_intent")
8386            .unwrap();
8387        let signal = actions
8388            .iter()
8389            .position(|action| *action == "terminate")
8390            .unwrap();
8391        assert!(
8392            intent < signal,
8393            "intent was not durable before signal: {actions:?}"
8394        );
8395
8396        let cleaned = harness.store.attempt(id).unwrap().unwrap();
8397        assert!(matches!(
8398            cleaned.outcome(),
8399            Some(AttemptOutcome::Failed {
8400                reason: FailureReason::TerminatedAfterRegistrationTimeout
8401            })
8402        ));
8403        let events = harness.events.events();
8404        let intent = events
8405            .iter()
8406            .position(|event| matches!(event, AttemptEvent::TerminateIntent { .. }))
8407            .unwrap();
8408        let stopped = events
8409            .iter()
8410            .position(|event| matches!(event, AttemptEvent::Terminated { .. }))
8411            .unwrap();
8412        let concluded = events
8413            .iter()
8414            .position(|event| matches!(event, AttemptEvent::Concluded { .. }))
8415            .unwrap();
8416        assert!(intent < stopped && stopped < concluded, "{events:?}");
8417    }
8418
8419    #[tokio::test]
8420    async fn timeout_crash_recovery_returns_the_same_replacement_intent() {
8421        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8422        let id = AttemptId::new_random();
8423        let runtime = harness
8424            .launcher
8425            .app_paths
8426            .runtime_dir()
8427            .join("timeout-after-crash");
8428        fs::create_dir_all(&runtime).unwrap();
8429        let mut attempt =
8430            RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
8431        attempt.jit_received(harness.clock.now()).unwrap();
8432        attempt.started(4242, harness.clock.now()).unwrap();
8433        harness.store.record_attempt(&attempt).unwrap();
8434        harness.processes.intent.store(true, Ordering::SeqCst);
8435        harness.processes.set_alive(false);
8436        harness
8437            .github
8438            .observe(GithubRunnerObservation::NotRegistered);
8439
8440        let replacements = harness
8441            .launcher
8442            .recover_startup(std::slice::from_ref(&harness.policy))
8443            .await
8444            .unwrap();
8445        assert_eq!(
8446            replacements,
8447            vec![ReplacementIntent {
8448                policy: harness.policy.id,
8449                previous_attempt: id,
8450                operation: "registration_timeout_replacement",
8451            }]
8452        );
8453        let consumed = RunnerLauncher::supervise(&harness.launcher, &harness.policy)
8454            .await
8455            .unwrap();
8456        assert_eq!(consumed, replacements);
8457        assert!(
8458            RunnerLauncher::supervise(&harness.launcher, &harness.policy)
8459                .await
8460                .unwrap()
8461                .is_empty(),
8462            "startup replacement evidence must be consumed exactly once by e1"
8463        );
8464        assert!(matches!(
8465            harness.store.attempt(id).unwrap().unwrap().outcome(),
8466            Some(AttemptOutcome::Failed {
8467                reason: FailureReason::TerminatedAfterRegistrationTimeout
8468            })
8469        ));
8470    }
8471
8472    #[tokio::test]
8473    async fn terminate_intent_sync_failure_prevents_signal_and_conclusion() {
8474        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8475        let id = AttemptId::new_random();
8476        let runtime = harness
8477            .launcher
8478            .app_paths
8479            .runtime_dir()
8480            .join("timeout-sync-failure");
8481        fs::create_dir_all(&runtime).unwrap();
8482        let mut attempt =
8483            RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
8484        attempt.jit_received(harness.clock.now()).unwrap();
8485        attempt.started(4242, harness.clock.now()).unwrap();
8486        harness.store.record_attempt(&attempt).unwrap();
8487        harness.clock.advance_secs(11);
8488        harness.processes.set_alive(true);
8489        harness.processes.fail_intent();
8490        harness
8491            .github
8492            .observe(GithubRunnerObservation::NotRegistered);
8493
8494        assert!(
8495            harness
8496                .launcher
8497                .recover_startup(std::slice::from_ref(&harness.policy))
8498                .await
8499                .is_err()
8500        );
8501        assert_eq!(harness.processes.terminations.load(Ordering::SeqCst), 0);
8502        assert!(harness.processes.alive.load(Ordering::SeqCst));
8503        assert_eq!(
8504            harness.store.attempt(id).unwrap().unwrap().state(),
8505            AttemptState::Starting
8506        );
8507        assert!(!harness.events.events().iter().any(|event| matches!(
8508            event,
8509            AttemptEvent::Terminated { attempt } | AttemptEvent::Concluded { attempt, .. }
8510                if *attempt == id
8511        )));
8512    }
8513
8514    #[tokio::test]
8515    async fn diagnostics_survive_cleanup_without_the_jit_or_a_token() {
8516        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8517        harness.ready().await;
8518        let attempt = harness.launch().await;
8519        harness
8520            .github
8521            .observe(GithubRunnerObservation::Registered { busy: false });
8522        harness.launcher.supervise(&harness.policy).await.unwrap();
8523        harness.clock.advance_secs(11);
8524        harness.processes.set_alive(false);
8525        harness
8526            .github
8527            .observe(GithubRunnerObservation::NotRegistered);
8528        harness.launcher.supervise(&harness.policy).await.unwrap();
8529        let diagnostic = fs::read_to_string(
8530            harness
8531                .launcher
8532                .diagnostics_root
8533                .join(format!("{}.log", attempt.id)),
8534        )
8535        .unwrap();
8536        assert!(diagnostic.contains("exited_idle_without_work"));
8537        assert!(!diagnostic.contains(JIT));
8538        assert!(!diagnostic.contains("ghp_"));
8539        assert!(!attempt.runtime_path().exists());
8540    }
8541
8542    #[test]
8543    fn native_process_listing_never_contains_jit_and_handoffs_never_survive() {
8544        let root = tempfile::tempdir().unwrap();
8545        let policy = fixtures::policy()
8546            .repository("octo/repo")
8547            .autoscale("home", 1)
8548            .active()
8549            .build();
8550        let runtime = root.path().join("successful");
8551        fs::create_dir_all(&runtime).unwrap();
8552        let processes = NativeProcesses::new();
8553        let config = EncodedJitConfig::new(JIT);
8554        let handoff =
8555            RestrictiveHandoff::create(&runtime, SecretString::from(config.expose().to_owned()))
8556                .unwrap();
8557        let mut child = native_inspection_spec()
8558            .spawn_runner_with_handoff(&handoff)
8559            .expect("native child starts");
8560        let pid = child.pid();
8561        handoff.delete().unwrap();
8562        let command_line = native_command_line(pid);
8563        assert!(
8564            !command_line.contains(JIT),
8565            "the encoded JIT configuration appeared in the native process listing"
8566        );
8567        assert_no_jit_file(&runtime);
8568        child
8569            .stop(Duration::from_secs(1))
8570            .expect("native child stops");
8571
8572        let failed_runtime = root.path().join("failed");
8573        fs::create_dir_all(&failed_runtime).unwrap();
8574        let failed = RunnerAttempt::allocate(
8575            AttemptId::new_random(),
8576            policy.id,
8577            &failed_runtime,
8578            FakeClock::default().now(),
8579        );
8580        assert!(
8581            processes
8582                .spawn(&failed, &EncodedJitConfig::new(JIT))
8583                .is_err(),
8584            "a runtime with no runner executable must fail"
8585        );
8586        assert_no_jit_file(&failed_runtime);
8587        processes
8588            .record_terminate_intent(&failed)
8589            .expect("the intent file and its directory entry are durably synced");
8590        assert_eq!(
8591            fs::read(NativeProcesses::intent_path(&failed)).unwrap(),
8592            b"registration-timeout\n"
8593        );
8594    }
8595
8596    #[test]
8597    fn post_spawn_boundaries_are_bounded_durable_and_never_retry_jit() {
8598        let root = tempfile::tempdir().unwrap();
8599        let policy = fixtures::policy()
8600            .repository("octo/repo")
8601            .autoscale("home", 1)
8602            .active()
8603            .build();
8604        let processes = NativeProcesses::new();
8605        processes.use_long_lived_test_listener();
8606        for (index, boundary) in [
8607            PostSpawnBoundary::HandoffDelete,
8608            PostSpawnBoundary::IdentitySerialize,
8609            PostSpawnBoundary::IdentityWrite,
8610            PostSpawnBoundary::ChildMapInsert,
8611        ]
8612        .into_iter()
8613        .enumerate()
8614        {
8615            let runtime = root.path().join(format!("post-spawn-{index}"));
8616            let bin = runtime.join("bin");
8617            fs::create_dir_all(&bin).unwrap();
8618            #[cfg(windows)]
8619            let listener = bin.join("Runner.Listener.exe");
8620            #[cfg(not(windows))]
8621            let listener = bin.join("Runner.Listener");
8622            fs::copy(std::env::current_exe().unwrap(), &listener).unwrap();
8623            let attempt = RunnerAttempt::allocate(
8624                AttemptId::new_random(),
8625                policy.id,
8626                &runtime,
8627                FakeClock::default().now(),
8628            );
8629            processes.fail_post_spawn_at(boundary);
8630            let failure = processes
8631                .spawn(&attempt, &EncodedJitConfig::new(JIT))
8632                .expect_err("fault must cross the post-spawn cleanup path");
8633            assert!(!failure.retryable, "{boundary:?} allowed duplicate retry");
8634            assert!(
8635                !processes.is_alive(&attempt).unwrap(),
8636                "{boundary:?} left a child"
8637            );
8638            assert!(!NativeProcesses::identity_path(&attempt).exists());
8639            assert_no_jit_file(&runtime);
8640        }
8641        assert_eq!(processes.post_spawn_reaps.load(Ordering::SeqCst), 4);
8642
8643        let runtime = root.path().join("identity-and-stop-fail");
8644        let bin = runtime.join("bin");
8645        fs::create_dir_all(&bin).unwrap();
8646        #[cfg(windows)]
8647        let listener = bin.join("Runner.Listener.exe");
8648        #[cfg(not(windows))]
8649        let listener = bin.join("Runner.Listener");
8650        fs::copy(std::env::current_exe().unwrap(), &listener).unwrap();
8651        let attempt = RunnerAttempt::allocate(
8652            AttemptId::new_random(),
8653            policy.id,
8654            &runtime,
8655            FakeClock::default().now(),
8656        );
8657        // The first fault rejects the normal identity write; the second rejects
8658        // its retry after the first stop fails. The fallback sidecar must make
8659        // the live-child result durable without an unbounded reap loop.
8660        processes.fail_post_spawn_at(PostSpawnBoundary::IdentityWrite);
8661        processes.fail_post_spawn_at(PostSpawnBoundary::IdentityWrite);
8662        processes.fail_next_post_spawn_stop();
8663        let failure = processes
8664            .spawn(&attempt, &EncodedJitConfig::new(JIT))
8665            .expect_err("the identity boundary must fail closed");
8666        assert!(failure.live_pid.is_some());
8667        assert_long_lived_listener_ready(&processes, &attempt);
8668        assert!(processes.is_alive(&attempt).unwrap());
8669        assert!(!NativeProcesses::identity_path(&attempt).exists());
8670        assert!(NativeProcesses::fallback_identity_path(&attempt).is_file());
8671        assert_eq!(processes.post_spawn_reaps.load(Ordering::SeqCst), 4);
8672        processes.terminate(&attempt).unwrap();
8673
8674        let runtime = root.path().join("persistent-stop-and-identity-failures");
8675        let bin = runtime.join("bin");
8676        fs::create_dir_all(&bin).unwrap();
8677        #[cfg(windows)]
8678        let listener = bin.join("Runner.Listener.exe");
8679        #[cfg(not(windows))]
8680        let listener = bin.join("Runner.Listener");
8681        fs::copy(std::env::current_exe().unwrap(), &listener).unwrap();
8682        let mut unresolved = RunnerAttempt::allocate(
8683            AttemptId::new_random(),
8684            policy.id,
8685            &runtime,
8686            FakeClock::default().now(),
8687        );
8688        for _ in 0..3 {
8689            processes.fail_post_spawn_at(PostSpawnBoundary::IdentityWrite);
8690        }
8691        processes.fail_post_spawn_stops(MAX_POST_SPAWN_STOP_ATTEMPTS);
8692        let failure = processes
8693            .spawn(&unresolved, &EncodedJitConfig::new(JIT))
8694            .expect_err("bounded cleanup must return even when every stop errors");
8695        let pid = failure
8696            .live_pid
8697            .expect("the owned child remains supervised in this invocation");
8698        assert!(matches!(failure.reason, FailureReason::Other(_)));
8699        assert_long_lived_listener_ready(&processes, &unresolved);
8700        unresolved.jit_received(FakeClock::default().now()).unwrap();
8701        unresolved.started(pid, FakeClock::default().now()).unwrap();
8702        let journal = SqliteStore::open_in_memory().unwrap();
8703        journal.record_attempt(&unresolved).unwrap();
8704        let recovered = journal.attempt(unresolved.id).unwrap().unwrap();
8705        assert_eq!(recovered.process_id(), Some(pid));
8706        assert_eq!(recovered.state(), AttemptState::Starting);
8707        assert!(processes.is_alive(&unresolved).unwrap());
8708        assert!(!NativeProcesses::identity_path(&unresolved).exists());
8709        assert!(!NativeProcesses::fallback_identity_path(&unresolved).exists());
8710        assert_eq!(
8711            fs::read_to_string(NativeProcesses::unresolved_process_path(&unresolved)).unwrap(),
8712            pid.to_string(),
8713            "bounded cleanup must leave durable unresolved-process evidence before returning"
8714        );
8715        assert!(
8716            NativeProcesses::new().is_alive(&recovered).is_err(),
8717            "restart must fail closed on the durable starting/PID journal rather than trust a bare PID"
8718        );
8719        processes.terminate(&unresolved).unwrap();
8720
8721        let runtime = root.path().join("post-spawn-stop-failed");
8722        let bin = runtime.join("bin");
8723        fs::create_dir_all(&bin).unwrap();
8724        #[cfg(windows)]
8725        let listener = bin.join("Runner.Listener.exe");
8726        #[cfg(not(windows))]
8727        let listener = bin.join("Runner.Listener");
8728        fs::copy(std::env::current_exe().unwrap(), &listener).unwrap();
8729        let attempt = RunnerAttempt::allocate(
8730            AttemptId::new_random(),
8731            policy.id,
8732            &runtime,
8733            FakeClock::default().now(),
8734        );
8735        processes.fail_post_spawn_at(PostSpawnBoundary::ChildMapInsert);
8736        processes.fail_next_post_spawn_stop();
8737        let failure = processes
8738            .spawn(&attempt, &EncodedJitConfig::new(JIT))
8739            .expect_err("the injected stop failure must preserve supervision");
8740        let live_pid = failure
8741            .live_pid
8742            .expect("live PID is returned to the journal");
8743        assert!(!failure.retryable);
8744        assert_long_lived_listener_ready(&processes, &attempt);
8745        assert!(NativeProcesses::identity_path(&attempt).is_file());
8746        assert_eq!(
8747            NativeProcesses::read_identity(&attempt)
8748                .unwrap()
8749                .unwrap()
8750                .pid(),
8751            live_pid
8752        );
8753        assert_eq!(processes.post_spawn_reaps.load(Ordering::SeqCst), 4);
8754        processes.terminate(&attempt).unwrap();
8755    }
8756
8757    #[test]
8758    #[ignore = "spawned only as the platform-stable native listener fixture"]
8759    fn long_lived_native_listener_helper() {
8760        let ready = std::env::var_os("RUNNER_MANAGER_TEST_LISTENER_READY")
8761            .map(PathBuf::from)
8762            .expect("the parent supplies the readiness path");
8763        fs::write(ready, b"ready\n").expect("the listener publishes readiness");
8764        std::thread::sleep(Duration::from_secs(30));
8765    }
8766
8767    fn assert_long_lived_listener_ready(processes: &NativeProcesses, attempt: &RunnerAttempt) {
8768        let ready = attempt.runtime_path().join(TEST_LISTENER_READY);
8769        let deadline = std::time::Instant::now() + Duration::from_secs(5);
8770        loop {
8771            if ready.is_file() {
8772                assert_eq!(fs::read(&ready).unwrap(), b"ready\n");
8773                return;
8774            }
8775            assert!(
8776                processes.is_alive(attempt).unwrap(),
8777                "the native listener exited before publishing readiness"
8778            );
8779            assert!(
8780                std::time::Instant::now() < deadline,
8781                "the native listener stayed alive but never published readiness"
8782            );
8783            std::thread::sleep(Duration::from_millis(10));
8784        }
8785    }
8786
8787    #[tokio::test]
8788    async fn every_production_launch_prunes_under_the_same_allocation_guard() {
8789        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8790        harness.ready().await;
8791        assert_eq!(harness.packages.prunes.load(Ordering::SeqCst), 0);
8792        harness.launch().await;
8793        assert_eq!(harness.packages.prunes.load(Ordering::SeqCst), 1);
8794        assert_eq!(
8795            *harness.packages.prune_currents.lock().unwrap(),
8796            vec![harness.packages.version.clone()],
8797            "the leased current version is an exclusion, never the prune target"
8798        );
8799    }
8800
8801    fn assert_no_jit_file(runtime: &Path) {
8802        for entry in fs::read_dir(runtime).unwrap() {
8803            let path = entry.unwrap().path();
8804            if path.is_file() {
8805                let bytes = fs::read(&path).unwrap();
8806                assert!(
8807                    !bytes
8808                        .windows(JIT.len())
8809                        .any(|window| window == JIT.as_bytes()),
8810                    "a JIT payload survived in a runtime file"
8811                );
8812            }
8813        }
8814    }
8815
8816    #[test]
8817    fn production_listener_command_uses_the_supported_jit_contract() {
8818        let runtime = Path::new("runtime");
8819        let spec = runner_listener_spec(
8820            PathBuf::from("Runner.Listener"),
8821            runtime,
8822            Vec::new(),
8823            &RunnerEnv::default(),
8824        );
8825        let arguments: Vec<_> = spec
8826            .arguments()
8827            .iter()
8828            .map(|argument| argument.to_string_lossy().into_owned())
8829            .collect();
8830
8831        assert_eq!(arguments, ["run"]);
8832        assert!(
8833            !arguments
8834                .iter()
8835                .any(|argument| argument == "--jit-config-file"),
8836            "the obsolete file option would be rejected by Runner.Listener 2.336.0"
8837        );
8838    }
8839
8840    /// What a production runner starts with: the platform defaults, the host's
8841    /// `runner.env` over them, the attempt's own temporary directory, and
8842    /// normal scheduling.
8843    #[test]
8844    fn production_listener_layers_runner_env_over_the_platform_defaults() {
8845        let root = tempfile::tempdir().unwrap();
8846        let runtime = root.path().join("attempt");
8847        let host_env =
8848            RunnerEnv::parse("ELECTRON_CACHE=/cache/electron\nHOME=/operator/chose/this\n")
8849                .unwrap();
8850        let caches = vec![
8851            (
8852                "ELECTRON_CACHE",
8853                std::ffi::OsString::from("/rman/_cache/o/r/electron"),
8854            ),
8855            (
8856                "npm_config_cache",
8857                std::ffi::OsString::from("/rman/_cache/o/r/npm"),
8858            ),
8859        ];
8860        let spec = runner_listener_spec(
8861            PathBuf::from("Runner.Listener"),
8862            &runtime,
8863            caches,
8864            &host_env,
8865        );
8866        let env = |name: &str| spec.configured_env(name).map(PathBuf::from);
8867
8868        assert!(spec.expects_normal_scheduling());
8869        assert_eq!(
8870            env("ELECTRON_CACHE"),
8871            Some(PathBuf::from("/cache/electron")),
8872            "runner.env must win over a dependency cache of the same name"
8873        );
8874        assert_eq!(
8875            env("npm_config_cache"),
8876            Some(PathBuf::from("/rman/_cache/o/r/npm"))
8877        );
8878        assert_eq!(
8879            env("HOME"),
8880            Some(PathBuf::from("/operator/chose/this")),
8881            "runner.env must win over a platform default of the same name"
8882        );
8883        for tmp in ["TMPDIR", "TEMP", "TMP"] {
8884            assert_eq!(env(tmp), Some(runtime.join("tmp")), "{tmp}");
8885        }
8886
8887        let home = runtime.join(runner_env::RUNNER_HOME_DIR);
8888        if cfg!(windows) {
8889            // Each runner's own profile, created inside its attempt, so that
8890            // concurrent jobs stop sharing the service account's.
8891            assert_eq!(env("USERPROFILE"), Some(home.clone()));
8892            assert_eq!(env("APPDATA"), Some(home.join(r"AppData\Roaming")));
8893            assert_eq!(env("LOCALAPPDATA"), Some(home.join(r"AppData\Local")));
8894            assert!(home.join(r"AppData\Roaming").is_dir());
8895            assert!(home.join(r"AppData\Local").is_dir());
8896        } else {
8897            // Unix keeps the account's own profile.
8898            assert_eq!(env("USERPROFILE"), None);
8899            assert!(!home.exists());
8900        }
8901    }
8902
8903    #[test]
8904    fn an_invalid_runner_env_fails_the_launch_without_echoing_a_value() {
8905        let root = tempfile::tempdir().unwrap();
8906        let path = runner_env::path_in(root.path());
8907        fs::write(&path, "GOOD=1\nghp_notarealtokenbutshapedlikeone\n").unwrap();
8908        let processes = NativeProcesses::new().with_runner_env_file(path.clone());
8909        let failure = processes.host_env().unwrap_err().to_string();
8910        assert!(
8911            failure.contains("runner.env") && failure.contains("line 2"),
8912            "{failure}"
8913        );
8914        assert!(
8915            !failure.contains("ghp_"),
8916            "the failure echoed a value: {failure}"
8917        );
8918
8919        // `prepare` runs before the JIT registration, so a broken file is
8920        // refused there, before GitHub is asked for anything.
8921        let policy = fixtures::policy()
8922            .repository("octo/repo")
8923            .autoscale("home", 1)
8924            .active()
8925            .build();
8926        let attempt = RunnerAttempt::allocate(
8927            AttemptId::new_random(),
8928            policy.id,
8929            root.path(),
8930            FakeClock::default().now(),
8931        );
8932        assert!(processes.prepare(&attempt, &policy).is_err());
8933
8934        fs::write(&path, "GOOD=1\n").unwrap();
8935        assert!(processes.prepare(&attempt, &policy).is_ok());
8936        assert_eq!(processes.host_env().unwrap().get("GOOD"), Some("1"));
8937        assert!(NativeProcesses::new().host_env().unwrap().is_empty());
8938    }
8939
8940    /// The wiring from `prepare` (which sees the policy) to `spawn` (which
8941    /// does not): the policy's repository decides the namespace, and a broken
8942    /// `caches.toml` is refused before GitHub is asked for anything.
8943    #[test]
8944    fn prepare_hands_the_policys_cache_namespace_to_the_launch() {
8945        let root = tempfile::tempdir().unwrap();
8946        let runner_root = root.path().join("rman");
8947        let runtime = runner_root.join("a1");
8948        fs::create_dir_all(runtime.join("bin")).unwrap();
8949        let config = runner_manager_platform::dependency_cache::config_path_in(root.path());
8950        let source_root = runner_root.clone();
8951        let processes = NativeProcesses::new().with_dependency_caches(DependencyCaches::new(
8952            config.clone(),
8953            Arc::new(move || Some(source_root.clone())),
8954            None,
8955        ));
8956        let policy = fixtures::policy()
8957            .repository("Octo/Repo")
8958            .autoscale("home", 1)
8959            .active()
8960            .build();
8961        let attempt = RunnerAttempt::allocate(
8962            AttemptId::new_random(),
8963            policy.id,
8964            &runtime,
8965            FakeClock::default().now(),
8966        );
8967        assert!(
8968            processes
8969                .cache_env(&attempt, None, &RunnerEnv::default())
8970                .is_empty(),
8971            "a launch with no prepared target, such as a bare `spawn`, has no cache"
8972        );
8973        let prepared = processes.prepare(&attempt, &policy).unwrap();
8974        let variables = processes.cache_env(
8975            &attempt,
8976            prepared.cache_target.as_ref(),
8977            &RunnerEnv::default(),
8978        );
8979        let npm = variables
8980            .iter()
8981            .find(|(name, _)| *name == "npm_config_cache")
8982            .map(|(_, value)| PathBuf::from(value));
8983        assert_eq!(
8984            npm,
8985            Some(
8986                runner_root
8987                    .join("_cache")
8988                    .join("octo")
8989                    .join("repo")
8990                    .join("npm")
8991            )
8992        );
8993
8994        fs::write(&config, "[tools]\nnot-a-tool = true\n").unwrap();
8995        let refused = processes
8996            .prepare(&attempt, &policy)
8997            .unwrap_err()
8998            .to_string();
8999        assert!(refused.contains("caches.toml"), "{refused}");
9000    }
9001
9002    #[cfg(windows)]
9003    fn native_inspection_spec() -> SpawnSpec {
9004        SpawnSpec::new("powershell.exe").args([
9005            "-NoProfile",
9006            "-NonInteractive",
9007            "-Command",
9008            "Start-Sleep -Seconds 30",
9009        ])
9010    }
9011
9012    #[cfg(unix)]
9013    fn native_inspection_spec() -> SpawnSpec {
9014        SpawnSpec::new("/bin/sh").args(["-c", "sleep 30"])
9015    }
9016
9017    #[cfg(windows)]
9018    fn native_command_line(pid: u32) -> String {
9019        let output = std::process::Command::new("powershell.exe")
9020            .args([
9021                "-NoProfile",
9022                "-NonInteractive",
9023                "-Command",
9024                &format!("(Get-CimInstance Win32_Process -Filter 'ProcessId = {pid}').CommandLine"),
9025            ])
9026            .output()
9027            .expect("PowerShell can inspect the native child");
9028        assert!(output.status.success(), "native process inspection failed");
9029        String::from_utf8(output.stdout).expect("Windows command lines are Unicode")
9030    }
9031
9032    #[cfg(target_os = "linux")]
9033    fn native_command_line(pid: u32) -> String {
9034        fs::read(format!("/proc/{pid}/cmdline"))
9035            .map(|bytes| String::from_utf8_lossy(&bytes).replace('\0', " "))
9036            .expect("/proc exposes the native child command line")
9037    }
9038
9039    #[cfg(target_os = "macos")]
9040    fn native_command_line(pid: u32) -> String {
9041        let output = std::process::Command::new("ps")
9042            .args(["-o", "command=", "-p", &pid.to_string()])
9043            .output()
9044            .expect("ps can inspect the native child");
9045        assert!(output.status.success(), "native process inspection failed");
9046        String::from_utf8(output.stdout).expect("the command line is UTF-8")
9047    }
9048
9049    // -- c2: persistent slot allocation -------------------------------------
9050
9051    #[tokio::test]
9052    async fn a_persistent_repository_leases_s1_and_journals_it_before_any_github_effect() {
9053        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
9054            .with_persistent_workspace(2);
9055        harness.github.watch_journal(Arc::clone(&harness.store));
9056        harness.ready().await;
9057
9058        let attempt = harness.launch().await;
9059
9060        assert_eq!(
9061            attempt.workspace(),
9062            AttemptWorkspace::persistent_slot(nz(1)),
9063            "the lowest free slot is leased"
9064        );
9065        assert_eq!(attempt.runtime_path(), harness.slot_path(1));
9066        assert!(attempt.holds_slot_lease());
9067        // The exact runtime path is journalled, not re-derived later.
9068        assert_eq!(
9069            harness.attempt(attempt.id).runtime_path(),
9070            harness.slot_path(1)
9071        );
9072
9073        // Step 7 of "Slot allocation": the lease exists before GitHub is asked
9074        // for anything, and the runner's work folder stays the relative `_work`
9075        // the slot root is laid out around.
9076        let facts = harness.github.registration_facts();
9077        assert_eq!(facts.len(), 1);
9078        assert_eq!(
9079            facts[0].leased_slots,
9080            vec![1],
9081            "the lease was journalled first"
9082        );
9083        assert_eq!(facts[0].work_folder, DEFAULT_WORK_FOLDER);
9084    }
9085
9086    #[tokio::test]
9087    async fn a_terminal_but_uncleaned_attempt_keeps_its_slot_without_holding_capacity() {
9088        let harness = Harness::new(
9089            FakeGithubLifecycle::default().fail(true),
9090            Arc::new(PersistentDemand),
9091        )
9092        .with_persistent_workspace(2);
9093        harness.ready().await;
9094
9095        // A terminal JIT refusal concludes the attempt without cleaning it.
9096        harness.launch_result().await.unwrap_err();
9097        let first = harness.store.attempts().unwrap().remove(0);
9098        assert_eq!(first.state(), AttemptState::Failed);
9099        assert!(
9100            !first.state().counts_against_capacity(),
9101            "a concluded attempt is invisible to host capacity"
9102        );
9103        assert!(
9104            first.holds_slot_lease(),
9105            "and still owns its directory, so its slot is not free"
9106        );
9107
9108        let second = harness.launch().await;
9109        assert_eq!(second.workspace(), AttemptWorkspace::persistent_slot(nz(2)));
9110        assert_eq!(second.runtime_path(), harness.slot_path(2));
9111        assert_eq!(
9112            harness
9113                .store
9114                .slot_leases_for_policy(harness.policy.id)
9115                .unwrap()
9116                .len(),
9117            2
9118        );
9119    }
9120
9121    #[tokio::test]
9122    async fn two_sequential_allocations_at_capacity_one_reuse_s1_and_its_retained_work() {
9123        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
9124            .with_persistent_workspace(1);
9125        harness.ready().await;
9126
9127        let first = harness.launch().await;
9128        assert_eq!(first.runtime_path(), harness.slot_path(1));
9129
9130        // What a job leaves behind, at the path the runner writes it to.
9131        let checkout = harness.slot_path(1).join(DEFAULT_WORK_FOLDER).join("repo");
9132        fs::create_dir_all(&checkout).unwrap();
9133        fs::write(checkout.join("checkout.txt"), b"from the first job").unwrap();
9134
9135        harness.cleanup_retaining_work(first.id).await;
9136
9137        let second = harness.launch().await;
9138        assert_ne!(second.id, first.id);
9139        assert_eq!(
9140            second.workspace(),
9141            AttemptWorkspace::persistent_slot(nz(1)),
9142            "a released slot is leased again rather than skipped"
9143        );
9144        assert_eq!(
9145            second.runtime_path(),
9146            first.runtime_path(),
9147            "the same slot is the same exact path"
9148        );
9149        assert_eq!(
9150            fs::read_to_string(checkout.join("checkout.txt")).unwrap(),
9151            "from the first job",
9152            "the retained job workspace survived the second allocation"
9153        );
9154        // The attempt's own runner material was recreated for this attempt.
9155        assert!(harness.slot_path(1).join("runner-package").exists());
9156    }
9157
9158    #[tokio::test]
9159    async fn lowering_capacity_leaves_higher_slots_alone_and_raising_it_permits_them_again() {
9160        let mut harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
9161            .with_persistent_workspace(2);
9162        harness.ready().await;
9163
9164        let first = harness.launch().await;
9165        let second = harness.launch().await;
9166        assert_eq!(second.runtime_path(), harness.slot_path(2));
9167        let kept = harness
9168            .slot_path(2)
9169            .join(DEFAULT_WORK_FOLDER)
9170            .join("kept.txt");
9171        fs::create_dir_all(kept.parent().unwrap()).unwrap();
9172        fs::write(&kept, b"s2 was here").unwrap();
9173        harness.cleanup_retaining_work(second.id).await;
9174
9175        // The operator lowers the ceiling while s1 is still leased.
9176        harness.policy.set_max_capacity(nz(1)).unwrap();
9177        let refusal = harness.launch_result().await.unwrap_err().to_string();
9178        assert!(
9179            refusal.contains("s1 to s1"),
9180            "the refusal names the ceiling it reached: {refusal}"
9181        );
9182        assert!(
9183            harness.slot_path(2).exists() && kept.exists(),
9184            "lowering capacity deletes nothing; the higher slot is merely unusable"
9185        );
9186
9187        // Raising it again makes the free higher slot available.
9188        harness.policy.set_max_capacity(nz(2)).unwrap();
9189        let third = harness.launch().await;
9190        assert_eq!(third.workspace(), AttemptWorkspace::persistent_slot(nz(2)));
9191        assert_eq!(third.runtime_path(), harness.slot_path(2));
9192        assert_eq!(fs::read_to_string(&kept).unwrap(), "s2 was here");
9193        assert!(first.holds_slot_lease(), "s1 was never disturbed");
9194    }
9195
9196    #[tokio::test]
9197    async fn organization_and_ephemeral_policies_never_enter_slot_allocation() {
9198        for policy in [
9199            fixtures::policy()
9200                .organization("octo")
9201                .autoscale("home", 2)
9202                .active()
9203                .build(),
9204            fixtures::policy()
9205                .repository("octo/repo")
9206                .autoscale("home", 2)
9207                .active()
9208                .build(),
9209        ] {
9210            let mut harness =
9211                Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
9212                    .with_host_runner_root();
9213            assert_eq!(policy.workspace_policy(), &WorkspacePolicy::Ephemeral);
9214            harness.policy = policy;
9215            harness.ready().await;
9216
9217            let attempt = harness.launch().await;
9218            assert_eq!(attempt.workspace(), AttemptWorkspace::Ephemeral);
9219            assert_eq!(attempt.workspace().slot_number(), None);
9220            assert!(!attempt.holds_slot_lease());
9221            assert_eq!(
9222                attempt.runtime_path().parent().unwrap(),
9223                harness.host_root(),
9224                "a disposable attempt is a child of the host root, never of a slot"
9225            );
9226            assert!(
9227                harness
9228                    .store
9229                    .slot_leases_for_policy(harness.policy.id)
9230                    .unwrap()
9231                    .is_empty()
9232            );
9233        }
9234    }
9235
9236    #[tokio::test]
9237    async fn two_concurrent_allocations_never_share_a_slot() {
9238        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
9239            .with_persistent_workspace(2);
9240        harness.github.watch_journal(Arc::clone(&harness.store));
9241        harness.ready().await;
9242
9243        // Both allocators race for the same host allocation lock, which is what
9244        // orders slot *selection*; each one then journals its lease before it
9245        // asks GitHub for anything.
9246        let (first, second) = tokio::join!(harness.launch_result(), harness.launch_result());
9247        let first = first.unwrap();
9248        let second = second.unwrap();
9249
9250        let slots: BTreeSet<u16> = [&first, &second]
9251            .iter()
9252            .map(|attempt| {
9253                attempt
9254                    .workspace()
9255                    .slot_number()
9256                    .expect("a persistent attempt leases a slot")
9257            })
9258            .collect();
9259        assert_eq!(slots, BTreeSet::from([1, 2]), "one slot each, never shared");
9260        assert_ne!(first.runtime_path(), second.runtime_path());
9261        assert_eq!(
9262            harness
9263                .store
9264                .slot_leases_for_policy(harness.policy.id)
9265                .unwrap()
9266                .len(),
9267            2
9268        );
9269
9270        // Every registration saw *its own* lease already in the journal. Reading
9271        // the whole journal and asking only that it be non-empty would pass on
9272        // the other allocator's lease, which is precisely the ordering bug this
9273        // test exists to exclude.
9274        let facts = harness.github.registration_facts();
9275        assert_eq!(facts.len(), 2);
9276        for fact in facts {
9277            let attempt = [&first, &second]
9278                .into_iter()
9279                .find(|attempt| runner_name(attempt.id) == fact.runner_name)
9280                .expect("every registration belongs to one of the two attempts");
9281            let slot = attempt
9282                .workspace()
9283                .slot_number()
9284                .expect("a persistent attempt leases a slot");
9285            assert!(
9286                fact.leased_slots.contains(&slot),
9287                "a JIT request never precedes its own lease: s{slot} not in {:?}",
9288                fact.leased_slots
9289            );
9290            assert_eq!(fact.work_folder, DEFAULT_WORK_FOLDER);
9291        }
9292    }
9293
9294    #[tokio::test]
9295    async fn the_database_is_the_final_fence_against_two_attempts_in_one_slot() {
9296        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
9297            .with_persistent_workspace(2);
9298        harness.ready().await;
9299        let first = harness.launch().await;
9300
9301        // What a second allocator that lost the race would write: the lock
9302        // orders selection, and this index is what catches a writer the lock
9303        // could not see.
9304        let clash = RunnerAttempt::allocate_in(
9305            AttemptId::new_random(),
9306            harness.policy.id,
9307            first.runtime_path(),
9308            AttemptWorkspace::persistent_slot(nz(1)),
9309            harness.clock.now(),
9310        );
9311        assert!(matches!(
9312            harness.store.record_attempt(&clash).unwrap_err(),
9313            StoreError::SlotAlreadyLeased { slot: 1, .. }
9314        ));
9315
9316        // And the launcher reports it as itself rather than as a generic
9317        // journal failure, so the operator reads what actually happened.
9318        let error = harness.launcher.record_allocation(&clash).unwrap_err();
9319        let rendered = error.to_string();
9320        assert!(rendered.contains("slot s1"), "{rendered}");
9321        assert!(rendered.contains("nothing was written"), "{rendered}");
9322        assert_eq!(
9323            harness.store.attempts().unwrap().len(),
9324            1,
9325            "the losing allocator journalled nothing"
9326        );
9327    }
9328
9329    #[test]
9330    fn slot_selection_fills_the_lowest_gap_and_stops_at_the_ceiling() {
9331        let leased = |slots: &[u16]| -> Vec<RunnerAttempt> {
9332            slots
9333                .iter()
9334                .map(|slot| {
9335                    RunnerAttempt::allocate_in(
9336                        AttemptId::new_random(),
9337                        fixtures::POLICY_ID,
9338                        format!("/srv/rman/acme/s{slot}"),
9339                        AttemptWorkspace::persistent_slot(nz(*slot)),
9340                        fixtures::created_at(),
9341                    )
9342                })
9343                .collect()
9344        };
9345
9346        assert_eq!(lowest_free_slot(&[], nz(1)), Some(nz(1)));
9347        assert_eq!(lowest_free_slot(&leased(&[1]), nz(4)), Some(nz(2)));
9348        // The gap a released middle slot leaves is filled before the tail.
9349        assert_eq!(lowest_free_slot(&leased(&[1, 3]), nz(4)), Some(nz(2)));
9350        // The ceiling is a refusal, never a reason to allocate past it.
9351        assert_eq!(lowest_free_slot(&leased(&[1]), nz(1)), None);
9352        assert_eq!(lowest_free_slot(&leased(&[1, 2]), nz(2)), None);
9353        // An ephemeral attempt holds no slot and cannot block one.
9354        let ephemeral = vec![RunnerAttempt::allocate(
9355            AttemptId::new_random(),
9356            fixtures::POLICY_ID,
9357            "/srv/rman/host/abc",
9358            fixtures::created_at(),
9359        )];
9360        assert_eq!(lowest_free_slot(&ephemeral, nz(1)), Some(nz(1)));
9361    }
9362
9363    #[test]
9364    fn a_slot_is_reusable_only_when_it_is_empty_or_holds_one_real_work_directory() {
9365        let root = tempfile::tempdir().unwrap();
9366        let slot = root.path().join("s1");
9367        fs::create_dir(&slot).unwrap();
9368        accept_reusable_slot(&slot).expect("an empty slot is reusable");
9369
9370        fs::create_dir(slot.join(DEFAULT_WORK_FOLDER)).unwrap();
9371        accept_reusable_slot(&slot).expect("a retained job workspace is reusable");
9372
9373        // Runner material a previous attempt left behind is refused rather than
9374        // reused or removed: deciding those bytes are safe is cleanup's job.
9375        fs::create_dir(slot.join("bin")).unwrap();
9376        fs::write(slot.join(".github-runner-id"), b"73").unwrap();
9377        let refusal = accept_reusable_slot(&slot).unwrap_err().to_string();
9378        assert!(refusal.contains("bin"), "{refusal}");
9379        assert!(refusal.contains(".github-runner-id"), "{refusal}");
9380
9381        // A `_work` that is not a real directory is not a job workspace.
9382        let file_work = root.path().join("s2");
9383        fs::create_dir(&file_work).unwrap();
9384        fs::write(file_work.join(DEFAULT_WORK_FOLDER), b"not a directory").unwrap();
9385        assert!(accept_reusable_slot(&file_work).is_err());
9386    }
9387
9388    #[cfg(unix)]
9389    #[test]
9390    fn a_link_shaped_work_directory_is_refused_rather_than_followed() {
9391        // Windows needs a privilege to create either kind of link, so the
9392        // link-shaped cases are asserted here; the rule itself is
9393        // platform-independent because it is `symlink_metadata`'s answer.
9394        let root = tempfile::tempdir().unwrap();
9395        let elsewhere = root.path().join("elsewhere");
9396        fs::create_dir(&elsewhere).unwrap();
9397
9398        let slot = root.path().join("s1");
9399        fs::create_dir(&slot).unwrap();
9400        std::os::unix::fs::symlink(&elsewhere, slot.join(DEFAULT_WORK_FOLDER)).unwrap();
9401        assert!(accept_reusable_slot(&slot).is_err());
9402
9403        let linked_slot = root.path().join("s2");
9404        std::os::unix::fs::symlink(&elsewhere, &linked_slot).unwrap();
9405        assert!(create_or_validate_slot(&linked_slot).is_err());
9406    }
9407
9408    #[test]
9409    fn a_slot_standing_where_a_file_is_refuses_rather_than_replacing_it() {
9410        let root = tempfile::tempdir().unwrap();
9411        let occupied = root.path().join("s1");
9412        fs::write(&occupied, b"an operator's file").unwrap();
9413        let refusal = create_or_validate_slot(&occupied).unwrap_err().to_string();
9414        assert!(refusal.contains("is not a directory"), "{refusal}");
9415        assert_eq!(fs::read_to_string(&occupied).unwrap(), "an operator's file");
9416
9417        let fresh = root.path().join("s2");
9418        create_or_validate_slot(&fresh).expect("a missing slot is created");
9419        assert!(fresh.is_dir());
9420        create_or_validate_slot(&fresh).expect("an existing directory is accepted");
9421    }
9422
9423    #[test]
9424    fn the_retained_work_directory_is_matched_the_way_the_filesystem_matches_it() {
9425        assert!(is_work_folder(OsStr::new(DEFAULT_WORK_FOLDER)));
9426        assert!(!is_work_folder(OsStr::new("_work2")));
9427        // A Windows filesystem is case-insensitive, so `_Work` *is* the retained
9428        // job workspace there and must never be removed as a leftover; on a
9429        // case-sensitive filesystem it is a different directory entirely.
9430        assert_eq!(is_work_folder(OsStr::new("_Work")), cfg!(windows));
9431    }
9432
9433    #[test]
9434    fn package_materialization_never_overwrites_or_follows_a_retained_work_directory() {
9435        let root = tempfile::tempdir().unwrap();
9436        let package = root.path().join("package");
9437        fs::create_dir_all(package.join("bin")).unwrap();
9438        fs::write(package.join("bin").join("Runner.Listener"), b"binary").unwrap();
9439        // A nested `_work` inside the package's own tree is an ordinary name.
9440        fs::create_dir_all(package.join("externals").join(DEFAULT_WORK_FOLDER)).unwrap();
9441
9442        let slot = root.path().join("s1");
9443        let retained = slot.join(DEFAULT_WORK_FOLDER).join("repo");
9444        fs::create_dir_all(&retained).unwrap();
9445        fs::write(retained.join("checkout.txt"), b"from the first job").unwrap();
9446
9447        copy_package_tree(&package, &slot, &TrustedTrees::default())
9448            .expect("the package lays out around `_work`");
9449        assert!(slot.join("bin").join("Runner.Listener").exists());
9450        assert!(
9451            slot.join("externals").join(DEFAULT_WORK_FOLDER).is_dir(),
9452            "the guard is top-level only"
9453        );
9454        assert_eq!(
9455            fs::read_to_string(retained.join("checkout.txt")).unwrap(),
9456            "from the first job"
9457        );
9458
9459        // A package that ever grew a top-level `_work` is refused, not merged.
9460        fs::create_dir(package.join(DEFAULT_WORK_FOLDER)).unwrap();
9461        let error = copy_package_tree(&package, &slot, &TrustedTrees::default()).unwrap_err();
9462        assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
9463        assert_eq!(
9464            fs::read_to_string(retained.join("checkout.txt")).unwrap(),
9465            "from the first job"
9466        );
9467    }
9468
9469    #[test]
9470    fn rolling_back_a_materialization_keeps_a_slot_but_removes_a_disposable_directory() {
9471        let root = tempfile::tempdir().unwrap();
9472
9473        let slot = root.path().join("s1");
9474        let retained = slot.join(DEFAULT_WORK_FOLDER);
9475        fs::create_dir_all(retained.join("repo")).unwrap();
9476        fs::write(retained.join("repo").join("checkout.txt"), b"kept").unwrap();
9477        fs::create_dir_all(slot.join("bin")).unwrap();
9478        fs::write(slot.join(".github-runner-id"), b"73").unwrap();
9479        let persistent = RunnerAttempt::allocate_in(
9480            AttemptId::new_random(),
9481            fixtures::POLICY_ID,
9482            &slot,
9483            AttemptWorkspace::persistent_slot(nz(1)),
9484            fixtures::created_at(),
9485        );
9486
9487        remove_materialized_package(&persistent).unwrap();
9488        assert!(slot.is_dir(), "the slot itself is not removed");
9489        assert!(!slot.join("bin").exists());
9490        assert!(!slot.join(".github-runner-id").exists());
9491        assert_eq!(
9492            fs::read_to_string(retained.join("repo").join("checkout.txt")).unwrap(),
9493            "kept"
9494        );
9495
9496        let disposable_path = root.path().join("abcdef012345");
9497        fs::create_dir_all(disposable_path.join(DEFAULT_WORK_FOLDER)).unwrap();
9498        let disposable = RunnerAttempt::allocate(
9499            AttemptId::new_random(),
9500            fixtures::POLICY_ID,
9501            &disposable_path,
9502            fixtures::created_at(),
9503        );
9504        remove_materialized_package(&disposable).unwrap();
9505        assert!(
9506            !disposable_path.exists(),
9507            "a disposable directory is still removed whole"
9508        );
9509    }
9510
9511    // -- c3: persistent cleanup and recovery --------------------------------
9512
9513    /// The runner state one attempt leaves at a slot root, as a real attempt
9514    /// leaves it: binaries, registration identity, a JIT handoff that outlived
9515    /// its process, and this agent's own lifecycle sidecars.
9516    ///
9517    /// Driven by [`SENSITIVE_SLOT_ENTRIES`] rather than by a second copy of it,
9518    /// so a name added to the thing cleanup must prove absent is a name every
9519    /// test here starts leaving behind.
9520    fn litter_the_slot(slot: &Path) {
9521        for directory in ["bin", "externals", "_diag"] {
9522            fs::create_dir_all(slot.join(directory)).unwrap();
9523        }
9524        fs::write(slot.join("bin").join("Runner.Listener"), b"binary").unwrap();
9525        for file in SENSITIVE_SLOT_ENTRIES
9526            .iter()
9527            .filter(|entry| !slot.join(entry).is_dir())
9528        {
9529            fs::write(slot.join(file), b"runner state").unwrap();
9530        }
9531        // A handoff whose owning process died before `Drop` could delete it.
9532        fs::write(
9533            slot.join(format!(
9534                "{}0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0.tmp",
9535                RestrictiveHandoff::NAME_PREFIX
9536            )),
9537            JIT.as_bytes(),
9538        )
9539        .unwrap();
9540    }
9541
9542    /// A marker under `_work` of the kind a job leaves for the next one.
9543    fn retain_under_work(slot: &Path) -> PathBuf {
9544        let checkout = slot.join(DEFAULT_WORK_FOLDER).join("repo").join("target");
9545        fs::create_dir_all(&checkout).unwrap();
9546        let marker = checkout.join("build-output.bin");
9547        fs::write(&marker, RETAINED).unwrap();
9548        marker
9549    }
9550
9551    /// What a job leaves under `_work` for the next job to reuse.
9552    const RETAINED: &str = "a Git-ignored build output the next job reuses";
9553
9554    /// Every direct entry of a directory, sorted, as plain strings.
9555    fn entries_of(directory: &Path) -> Vec<String> {
9556        let mut names: Vec<String> = fs::read_dir(directory)
9557            .unwrap()
9558            .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
9559            .collect();
9560        names.sort();
9561        names
9562    }
9563
9564    /// The one entry a cleaned slot is allowed to hold.
9565    fn only_the_job_workspace() -> Vec<String> {
9566        vec![DEFAULT_WORK_FOLDER.to_owned()]
9567    }
9568
9569    #[cfg(unix)]
9570    #[test]
9571    fn disposable_tree_removal_does_not_open_a_dotnet_diagnostic_fifo() {
9572        use std::sync::mpsc;
9573
9574        let temporary = tempfile::tempdir().unwrap();
9575        let tree = temporary.path().join("attempt");
9576        let diagnostic = tree.join("tmp/clr-debug-pipe-runner-in");
9577        fs::create_dir_all(diagnostic.parent().unwrap()).unwrap();
9578        assert!(
9579            std::process::Command::new("mkfifo")
9580                .arg(&diagnostic)
9581                .status()
9582                .unwrap()
9583                .success()
9584        );
9585
9586        let (finished, result) = mpsc::channel();
9587        std::thread::spawn(move || {
9588            let removed = remove_runtime_tree(&tree);
9589            let _ = finished.send(removed);
9590        });
9591
9592        result
9593            .recv_timeout(Duration::from_secs(2))
9594            .expect("runtime deletion must not wait for a FIFO peer")
9595            .unwrap();
9596        assert!(!diagnostic.exists());
9597    }
9598
9599    /// The junction WinINet leaves in a job's profile, reproduced: its own DACL
9600    /// denies `Everyone` the right to list it.
9601    #[cfg(windows)]
9602    #[test]
9603    fn disposable_tree_removal_unlinks_a_junction_nobody_may_list() {
9604        let temporary = tempfile::tempdir().unwrap();
9605        let tree = temporary.path().join("attempt");
9606        let cache = tree.join(r"tmp\home\AppData\Local\Microsoft\Windows\INetCache");
9607        let outside = temporary.path().join("outside");
9608        fs::create_dir_all(&cache).unwrap();
9609        fs::create_dir_all(&outside).unwrap();
9610        fs::write(outside.join("keep.txt"), b"not the attempt's").unwrap();
9611        fs::write(tree.join("tmp").join("job.log"), b"job output").unwrap();
9612        let junction = cache.join("Content.IE5");
9613        let created = std::process::Command::new("cmd")
9614            .arg("/C")
9615            .arg("mklink")
9616            .arg("/J")
9617            .arg(&junction)
9618            .arg(&outside)
9619            .output()
9620            .unwrap();
9621        assert!(created.status.success(), "{created:?}");
9622        // `/L` puts the entry on the junction itself, as WinINet does, rather
9623        // than on the directory it points at. `*S-1-1-0` is `Everyone` in every
9624        // display language.
9625        let denied = std::process::Command::new("icacls")
9626            .arg(&junction)
9627            .args(["/L", "/deny", "*S-1-1-0:(RD)"])
9628            .output()
9629            .unwrap();
9630        assert!(denied.status.success(), "{denied:?}");
9631
9632        // The control: without the fix the tree cannot be removed at all.
9633        let unfixed = remove_dir_all::remove_dir_all(&tree)
9634            .expect_err("the fixture no longer reproduces the WinINet junction");
9635        assert_eq!(unfixed.kind(), std::io::ErrorKind::PermissionDenied);
9636        assert!(junction.symlink_metadata().is_ok());
9637
9638        remove_runtime_tree(&tree).expect("a tree holding a deny-list junction is removed");
9639        assert!(!tree.exists(), "the attempt tree survived");
9640        assert_eq!(
9641            fs::read(outside.join("keep.txt")).unwrap(),
9642            b"not the attempt's",
9643            "removing the junction followed it into the directory it points at"
9644        );
9645    }
9646
9647    /// One slot entry that refuses to be removed, and the undo that lets the
9648    /// temporary directory be torn down afterwards.
9649    ///
9650    /// The two operating systems refuse for different reasons and there is no
9651    /// portable third. Windows will not open a file for deletion while a handle
9652    /// with share mode zero is held on it; Unix will not unlink from a directory
9653    /// the caller cannot write. Both are states a real machine reaches -- a
9654    /// scanner holding a file open, a job that left a directory read-only -- so
9655    /// the injection is a filesystem fact rather than a seam cut into the
9656    /// product for a test to pull.
9657    ///
9658    /// The Unix half is a permission, and permissions do not apply to `root`.
9659    /// [`Self::inject`] proves the block on a throwaway directory before
9660    /// claiming it, so a suite running as `root` says it could not inject rather
9661    /// than asserting nothing and passing.
9662    struct BlockedDeletion {
9663        directory: PathBuf,
9664        #[cfg(windows)]
9665        _handle: fs::File,
9666    }
9667
9668    impl BlockedDeletion {
9669        const HELD: &'static str = "held-open";
9670
9671        /// Fill `directory` with a file that cannot be removed, or answer `None`
9672        /// when this account cannot be stopped from removing anything.
9673        fn inject(directory: &Path) -> Option<Self> {
9674            #[cfg(unix)]
9675            if !Self::refusal_is_possible() {
9676                return None;
9677            }
9678            fs::create_dir_all(directory).unwrap();
9679            fs::write(
9680                directory.join(Self::HELD),
9681                b"a file the scrub cannot remove",
9682            )
9683            .unwrap();
9684            #[cfg(windows)]
9685            let handle = {
9686                use std::os::windows::fs::OpenOptionsExt;
9687
9688                fs::OpenOptions::new()
9689                    .read(true)
9690                    .share_mode(0)
9691                    .open(directory.join(Self::HELD))
9692                    .expect("the blocking handle opens")
9693            };
9694            #[cfg(unix)]
9695            Self::set_mode(directory, 0o555);
9696            Some(Self {
9697                directory: directory.to_path_buf(),
9698                #[cfg(windows)]
9699                _handle: handle,
9700            })
9701        }
9702
9703        fn release(self) {
9704            drop(self);
9705        }
9706
9707        #[cfg(unix)]
9708        fn refusal_is_possible() -> bool {
9709            let probe = tempfile::tempdir().unwrap();
9710            let directory = probe.path().join("probe");
9711            fs::create_dir(&directory).unwrap();
9712            fs::write(directory.join("file"), b"probe").unwrap();
9713            Self::set_mode(&directory, 0o555);
9714            let refused = fs::remove_dir_all(&directory).is_err();
9715            Self::set_mode(&directory, 0o755);
9716            refused
9717        }
9718
9719        #[cfg(unix)]
9720        fn set_mode(directory: &Path, mode: u32) {
9721            use std::os::unix::fs::PermissionsExt;
9722
9723            let mut permissions = fs::metadata(directory).unwrap().permissions();
9724            permissions.set_mode(mode);
9725            fs::set_permissions(directory, permissions).unwrap();
9726        }
9727    }
9728
9729    impl Drop for BlockedDeletion {
9730        fn drop(&mut self) {
9731            #[cfg(unix)]
9732            Self::set_mode(&self.directory, 0o755);
9733            #[cfg(not(unix))]
9734            let _ = &self.directory;
9735        }
9736    }
9737
9738    #[tokio::test]
9739    async fn two_sequential_jobs_keep_the_checkout_and_start_without_the_earlier_runner_state() {
9740        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
9741            .with_persistent_workspace(1);
9742        harness.ready().await;
9743
9744        let first = harness.launch().await;
9745        let slot = harness.slot_path(1);
9746        assert_eq!(first.runtime_path(), slot);
9747        assert_eq!(
9748            read_runner_id(&slot),
9749            Some(73),
9750            "the attempt registered, so its identity is on disk"
9751        );
9752        let marker = retain_under_work(&slot);
9753        litter_the_slot(&slot);
9754
9755        harness.cleanup_retaining_work(first.id).await;
9756
9757        // The allowlist is exactly one entry, so this assertion is the security
9758        // property in full: what is retained, and that nothing else is.
9759        assert_eq!(entries_of(&slot), only_the_job_workspace());
9760        assert_eq!(fs::read_to_string(&marker).unwrap(), RETAINED);
9761        assert_eq!(
9762            read_runner_id(&slot),
9763            None,
9764            "the first attempt's registration identity is gone before the second starts"
9765        );
9766        assert_eq!(harness.attempt(first.id).state(), AttemptState::Cleaned);
9767        assert!(!harness.attempt(first.id).holds_slot_lease());
9768
9769        let second = harness.launch().await;
9770        assert_ne!(second.id, first.id);
9771        assert_eq!(second.workspace(), AttemptWorkspace::persistent_slot(nz(1)));
9772        assert_eq!(
9773            second.runtime_path(),
9774            slot,
9775            "the same slot, so the same retained `_work`"
9776        );
9777        assert_eq!(fs::read_to_string(&marker).unwrap(), RETAINED);
9778    }
9779
9780    #[tokio::test]
9781    async fn cleaning_a_persistent_slot_needs_no_policy_and_scans_no_directory_for_ownership() {
9782        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
9783            .with_persistent_workspace(1);
9784        harness.ready().await;
9785        let attempt = harness.launch().await;
9786        let slot = harness.slot_path(1);
9787        let marker = retain_under_work(&slot);
9788        litter_the_slot(&slot);
9789        harness.conclude(attempt.id);
9790
9791        // A repository removed from the product between the attempt concluding
9792        // and the sweep reaching it. The journalled runtime path and slot are
9793        // the only facts left, and `04-security-recovery.md` requires them to be
9794        // enough: the alternative is scanning a root to work out which
9795        // directories were ours, which invariant 6 forbids.
9796        harness
9797            .store
9798            .remove_policy(harness.policy.id, harness.policy.revision())
9799            .unwrap();
9800        assert!(harness.store.policy(harness.policy.id).unwrap().is_none());
9801
9802        harness
9803            .launcher
9804            .clean(attempt.id)
9805            .await
9806            .expect("journal facts alone are enough to clean the slot");
9807
9808        assert_eq!(entries_of(&slot), only_the_job_workspace());
9809        assert!(marker.exists());
9810        assert_eq!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
9811    }
9812
9813    #[tokio::test]
9814    async fn an_injected_partial_deletion_quarantines_the_slot_across_a_restart() {
9815        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
9816            .with_persistent_workspace(2);
9817        harness.ready().await;
9818        let first = harness.launch().await;
9819        let slot = harness.slot_path(1);
9820        let marker = retain_under_work(&slot);
9821        litter_the_slot(&slot);
9822        harness.conclude(first.id);
9823
9824        let Some(block) = BlockedDeletion::inject(&slot.join("bin")) else {
9825            eprintln!(
9826                "skipped: this account cannot be refused a deletion, so no partial deletion can \
9827                 be injected"
9828            );
9829            return;
9830        };
9831
9832        let refusal = harness
9833            .launcher
9834            .clean(first.id)
9835            .await
9836            .expect_err("a deletion that failed may not report a cleaned slot");
9837        let rendered = refusal.reason.to_string();
9838        assert!(rendered.contains("could not be removed"), "{rendered}");
9839
9840        let held = harness.attempt(first.id);
9841        assert_eq!(held.state(), AttemptState::Failed, "still not cleaned");
9842        assert!(held.holds_slot_lease(), "so the slot is still leased");
9843        assert!(
9844            !held.state().counts_against_capacity(),
9845            "and a concluded attempt still costs the host no capacity"
9846        );
9847
9848        // The same journal and the same directories, under a launcher that
9849        // remembers nothing. Recovery must complete: a host that can launch
9850        // nothing at all because one slot is stuck is not what "does not count
9851        // as active host capacity" means.
9852        let restarted = harness.restart();
9853        restarted
9854            .recover_startup(std::slice::from_ref(&harness.policy))
9855            .await
9856            .expect("one quarantined slot does not stop the host recovering");
9857        assert_eq!(
9858            harness.attempt(first.id).state(),
9859            AttemptState::Failed,
9860            "the quarantine survived the restart"
9861        );
9862        assert!(
9863            harness
9864                .reconcile_events
9865                .events()
9866                .iter()
9867                .any(|event| matches!(
9868                    event,
9869                    LifecycleEvent::AttemptCleanFailed {
9870                        reason: "slot_entry_could_not_be_removed",
9871                        ..
9872                    }
9873                )),
9874            "the refusal is reported rather than retried in silence"
9875        );
9876
9877        // Capacity two, slot one quarantined: the next attempt goes to s2 and
9878        // never to the slot still holding runner state.
9879        let guard = harness.allocation_lock.acquire().await.unwrap();
9880        let second = restarted
9881            .launch(LaunchRequest {
9882                host: &harness.host,
9883                policy: &harness.policy,
9884                allocation_guard: &guard,
9885                lock_wait: Duration::ZERO,
9886            })
9887            .await
9888            .expect("the host can still launch");
9889        assert_eq!(second.workspace(), AttemptWorkspace::persistent_slot(nz(2)));
9890        drop(guard);
9891
9892        // And the same cleanup succeeds once the obstruction is gone, which is
9893        // what "retry through normal recovery" has to mean.
9894        block.release();
9895        restarted
9896            .clean(first.id)
9897            .await
9898            .expect("the retried cleanup completes");
9899        assert_eq!(entries_of(&slot), only_the_job_workspace());
9900        assert!(marker.exists());
9901        assert_eq!(harness.attempt(first.id).state(), AttemptState::Cleaned);
9902    }
9903
9904    /// The disposable half of the same injection, which had no test of its own.
9905    ///
9906    /// `scrub_workspace`'s ephemeral arm turns a failed `remove_dir_all` into
9907    /// `"attempt workspace could not be removed"`, and until now that branch was
9908    /// only reachable in theory: every injected-deletion test drove a persistent
9909    /// slot. The property is the same one and matters for the same reason --
9910    /// `04-security-recovery.md`'s "Cleanup partly fails and the slot is reused
9911    /// anyway" -- but the disposable guarantee is stronger, so a cleanup that
9912    /// reported success over a directory it had not removed would be the
9913    /// contamination gate failing silently rather than a slot being held.
9914    #[tokio::test]
9915    async fn an_injected_deletion_failure_leaves_a_disposable_attempt_uncleaned() {
9916        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
9917        harness.ready().await;
9918        let attempt = harness.launch().await;
9919        let runtime = attempt.runtime_path().to_path_buf();
9920        assert_eq!(attempt.workspace(), AttemptWorkspace::Ephemeral);
9921        harness.conclude(attempt.id);
9922
9923        let Some(block) = BlockedDeletion::inject(&runtime.join("held-open-subdirectory")) else {
9924            eprintln!(
9925                "skipped: this account cannot be refused a deletion, so no partial deletion can be injected"
9926            );
9927            return;
9928        };
9929
9930        let refusal = harness
9931            .launcher
9932            .clean(attempt.id)
9933            .await
9934            .expect_err("a deletion that failed may not report a removed workspace");
9935        let rendered = refusal.reason.to_string();
9936        assert!(
9937            rendered.contains("could not be removed"),
9938            "the refusal names what happened: {rendered}"
9939        );
9940        assert_ne!(
9941            harness.attempt(attempt.id).state(),
9942            AttemptState::Cleaned,
9943            "an attempt whose directory is still on disk is not cleaned"
9944        );
9945        assert!(
9946            runtime.is_dir(),
9947            "the directory the removal could not finish is still there, which is the fact the journal must keep agreeing with"
9948        );
9949
9950        let restarted = harness.restart();
9951        restarted
9952            .recover_startup(std::slice::from_ref(&harness.policy))
9953            .await
9954            .expect("one locked ephemeral workspace does not stop startup recovery");
9955        assert!(
9956            harness
9957                .reconcile_events
9958                .events()
9959                .iter()
9960                .any(|event| matches!(
9961                    event,
9962                    LifecycleEvent::AttemptCleanFailed {
9963                        attempt: failed_attempt,
9964                        reason: "ephemeral_workspace_could_not_be_removed",
9965                        ..
9966                    } if *failed_attempt == attempt.id
9967                )),
9968            "the deferred cleanup remains visible while the daemon keeps running"
9969        );
9970
9971        // And the ordinary retry -- the reconciler's next terminal sweep --
9972        // finishes it once the obstruction is gone.
9973        block.release();
9974        harness
9975            .launcher
9976            .clean(attempt.id)
9977            .await
9978            .expect("the retried cleanup completes");
9979        assert!(!runtime.exists(), "the whole attempt directory goes");
9980        assert_eq!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
9981    }
9982
9983    /// Supervision retries a workspace it cannot remove with per-attempt
9984    /// backoff, so a stuck workspace is reported once per delay rather than on
9985    /// every pass.
9986    #[tokio::test]
9987    async fn supervision_backs_off_a_workspace_it_cannot_remove() {
9988        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
9989        harness.ready().await;
9990        let attempt = harness.launch().await;
9991        let runtime = attempt.runtime_path().to_path_buf();
9992        harness.conclude(attempt.id);
9993        let Some(block) = BlockedDeletion::inject(&runtime.join("held-open-subdirectory")) else {
9994            eprintln!(
9995                "skipped: this account cannot be refused a deletion, so no partial deletion can be injected"
9996            );
9997            return;
9998        };
9999        let failures = || {
10000            harness
10001                .reconcile_events
10002                .events()
10003                .iter()
10004                .filter(|event| {
10005                    matches!(
10006                        event,
10007                        LifecycleEvent::AttemptCleanFailed { attempt: failed, .. }
10008                            if *failed == attempt.id
10009                    )
10010                })
10011                .count()
10012        };
10013        let supervise = async || {
10014            harness
10015                .launcher
10016                .supervise(&harness.policy)
10017                .await
10018                .expect("a stuck workspace does not fail supervision");
10019        };
10020
10021        supervise().await;
10022        assert_eq!(failures(), 1);
10023        supervise().await;
10024        assert_eq!(
10025            failures(),
10026            1,
10027            "the failed cleanup was retried on the very next pass"
10028        );
10029        harness.clock.advance_secs(31);
10030        supervise().await;
10031        assert_eq!(
10032            failures(),
10033            2,
10034            "the cleanup was not retried once its delay passed"
10035        );
10036
10037        block.release();
10038        harness.clock.advance_secs(61);
10039        supervise().await;
10040        assert!(
10041            !runtime.exists(),
10042            "the retry removes the workspace once it can"
10043        );
10044        assert_eq!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
10045    }
10046
10047    #[tokio::test]
10048    async fn changing_a_repository_back_to_ephemeral_leaves_every_old_slot_untouched() {
10049        let mut harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
10050            .with_persistent_workspace(1);
10051        harness.ready().await;
10052        let first = harness.launch().await;
10053        let slot = harness.slot_path(1);
10054        let marker = retain_under_work(&slot);
10055        harness.cleanup_retaining_work(first.id).await;
10056
10057        // Every attempt for this policy is cleaned, so the mutation is allowed
10058        // (`04-security-recovery.md`, "Recovery rules"). What it must not do is
10059        // move or delete anything the operator still owns.
10060        harness
10061            .policy
10062            .set_workspace_policy(WorkspacePolicy::Ephemeral)
10063            .unwrap();
10064
10065        let second = harness.launch().await;
10066        assert_eq!(second.workspace(), AttemptWorkspace::Ephemeral);
10067        assert_eq!(
10068            second.runtime_path().parent().unwrap(),
10069            harness.host_root(),
10070            "a disposable attempt is a child of the host root"
10071        );
10072        assert!(slot.is_dir(), "the old slot is left where it stands");
10073        assert_eq!(fs::read_to_string(&marker).unwrap(), RETAINED);
10074
10075        // And cleaning the disposable attempt removes its own directory whole
10076        // without reaching the retained slot beside it.
10077        harness.conclude(second.id);
10078        harness.launcher.clean(second.id).await.unwrap();
10079        assert!(!second.runtime_path().exists());
10080        assert!(marker.exists());
10081    }
10082
10083    #[tokio::test]
10084    async fn a_persistent_slot_is_scrubbed_only_after_the_process_is_signalled_and_gone() {
10085        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
10086            .with_persistent_workspace(1);
10087        harness.ready().await;
10088        let slot = harness.slot_path(1);
10089        fs::create_dir_all(&slot).unwrap();
10090        let marker = retain_under_work(&slot);
10091        litter_the_slot(&slot);
10092
10093        let id = AttemptId::new_random();
10094        let mut attempt = RunnerAttempt::allocate_in(
10095            id,
10096            harness.policy.id,
10097            &slot,
10098            AttemptWorkspace::persistent_slot(nz(1)),
10099            harness.clock.now(),
10100        );
10101        attempt.jit_received(harness.clock.now()).unwrap();
10102        attempt.started(4242, harness.clock.now()).unwrap();
10103        harness.store.record_attempt(&attempt).unwrap();
10104        harness.clock.advance_secs(11);
10105        harness.processes.set_alive(true);
10106        harness
10107            .github
10108            .observe(GithubRunnerObservation::NotRegistered);
10109
10110        harness.launcher.supervise(&harness.policy).await.unwrap();
10111
10112        // The identity and termination ordering `e3` established is unchanged by
10113        // the workspace kind: the intent is durable before the signal, and the
10114        // slot is scrubbed only once the process is gone.
10115        let actions = harness.processes.actions.lock().unwrap().clone();
10116        let intent = actions
10117            .iter()
10118            .position(|action| *action == "terminate_intent")
10119            .unwrap();
10120        let signal = actions
10121            .iter()
10122            .position(|action| *action == "terminate")
10123            .unwrap();
10124        assert!(intent < signal, "{actions:?}");
10125        assert!(!harness.processes.alive.load(Ordering::SeqCst));
10126
10127        let cleaned = harness.attempt(id);
10128        assert_eq!(cleaned.state(), AttemptState::Cleaned);
10129        assert!(matches!(
10130            cleaned.outcome(),
10131            Some(AttemptOutcome::Failed {
10132                reason: FailureReason::TerminatedAfterRegistrationTimeout
10133            })
10134        ));
10135        assert_eq!(entries_of(&slot), only_the_job_workspace());
10136        assert!(marker.exists());
10137    }
10138
10139    #[test]
10140    fn a_scrub_retains_one_real_work_directory_and_removes_every_other_entry() {
10141        let root = tempfile::tempdir().unwrap();
10142        let slot = root.path().join("s1");
10143        fs::create_dir(&slot).unwrap();
10144        let marker = retain_under_work(&slot);
10145        litter_the_slot(&slot);
10146        fs::write(slot.join("runner-package"), b"verified").unwrap();
10147
10148        scrub_slot_entries(&slot).expect("a slot of ordinary runner state scrubs");
10149        verify_slot_scrubbed(&slot).expect("and proves it afterwards");
10150
10151        assert_eq!(entries_of(&slot), only_the_job_workspace());
10152        assert!(marker.exists());
10153    }
10154
10155    #[test]
10156    fn a_residue_refusal_never_reports_the_under_count_as_the_fact() {
10157        let slot = Path::new("/runners/s1");
10158
10159        // The ordinary case: the listing counted, so the count is the fact and
10160        // the published names qualify it.
10161        let counted = residue_detail(slot, 2, &["`bin`".to_owned()]);
10162        assert!(counted.contains("2 entries other than"), "{counted}");
10163        assert!(counted.contains("including `bin`"), "{counted}");
10164        assert_eq!(
10165            residue_detail(slot, 1, &[]),
10166            format!(
10167                "1 entry other than `{DEFAULT_WORK_FOLDER}` survived cleanup of {}",
10168                slot.display()
10169            )
10170        );
10171
10172        // The race the second pass exists for: the listing saw nothing and the
10173        // filesystem answered otherwise. Saying "0 entries survived" here would
10174        // state the under-count as the fact and contradict the rest of the
10175        // sentence.
10176        let raced = residue_detail(slot, 0, &["`.credentials`".to_owned()]);
10177        assert!(!raced.contains('0'), "{raced}");
10178        assert!(raced.contains("reported nothing but"), "{raced}");
10179        assert!(raced.contains("`.credentials` survived cleanup"), "{raced}");
10180    }
10181
10182    #[test]
10183    fn verification_asks_the_filesystem_rather_than_the_listing_that_missed_an_entry() {
10184        let root = tempfile::tempdir().unwrap();
10185        let slot = root.path().join("s1");
10186        fs::create_dir(&slot).unwrap();
10187        fs::create_dir(slot.join(DEFAULT_WORK_FOLDER)).unwrap();
10188        verify_slot_scrubbed(&slot).expect("only `_work` is a clean slot");
10189
10190        // Runner binaries, registration identity, process identity and this
10191        // agent's lifecycle marks, one at a time, so a scrub that skipped
10192        // exactly one is still caught.
10193        for survivor in ["bin", ".credentials", IDENTITY_FILE, RUNNER_ID_FILE] {
10194            fs::write(slot.join(survivor), b"left behind").unwrap();
10195            let quarantine = verify_slot_scrubbed(&slot).unwrap_err();
10196            assert_eq!(quarantine.refusal, SlotRefusal::Residue);
10197            assert!(
10198                quarantine.detail.contains(&format!("`{survivor}`")),
10199                "{quarantine}"
10200            );
10201            fs::remove_file(slot.join(survivor)).unwrap();
10202        }
10203
10204        // A handoff is named by its published prefix, never by the UUID that
10205        // follows it, and never by the payload it holds.
10206        let handoff = slot.join(format!("{}whatever.tmp", RestrictiveHandoff::NAME_PREFIX));
10207        fs::write(&handoff, JIT.as_bytes()).unwrap();
10208        let quarantine = verify_slot_scrubbed(&slot).unwrap_err();
10209        assert!(
10210            quarantine.detail.contains("an encoded JIT handoff"),
10211            "{quarantine}"
10212        );
10213        assert!(!quarantine.detail.contains(JIT), "{quarantine}");
10214        fs::remove_file(&handoff).unwrap();
10215
10216        // A name a workflow chose is counted and never echoed: a slot root is
10217        // writable by the job, so a file named after a secret would be published
10218        // by any message that repeated the listing.
10219        fs::write(slot.join("ghp_DO_NOT_LEAK"), b"named by the job").unwrap();
10220        let quarantine = verify_slot_scrubbed(&slot).unwrap_err();
10221        assert!(
10222            quarantine.detail.contains("1 entry other than"),
10223            "{quarantine}"
10224        );
10225        assert!(
10226            !quarantine.detail.contains("ghp_DO_NOT_LEAK"),
10227            "{quarantine}"
10228        );
10229    }
10230
10231    #[test]
10232    fn a_slot_is_derived_from_the_journal_and_refused_when_it_disagrees() {
10233        let root = tempfile::tempdir().unwrap();
10234        let configured =
10235            LocalAbsolutePath::new(root.path().to_str().unwrap()).expect("a local absolute root");
10236        let slot = configured.as_path().join("s1");
10237        fs::create_dir(&slot).unwrap();
10238
10239        verify_journalled_slot(&slot, nz(1), Some(&configured))
10240            .expect("the journalled slot agrees");
10241        verify_journalled_slot(&slot, nz(1), None)
10242            .expect("and a policy that is gone removes a check, not the ability to clean");
10243
10244        // The journalled slot number is what names the directory. `s1` recorded
10245        // as slot two is corrupt state, not a slot to clean.
10246        assert_eq!(
10247            verify_journalled_slot(&slot, nz(2), None)
10248                .unwrap_err()
10249                .refusal,
10250            SlotRefusal::NotTheJournalledSlot
10251        );
10252        for stray in ["s1/nested", "not-a-slot", "s01"] {
10253            let path = configured.as_path().join(stray);
10254            assert_eq!(
10255                verify_journalled_slot(&path, nz(1), None)
10256                    .unwrap_err()
10257                    .refusal,
10258                SlotRefusal::NotTheJournalledSlot,
10259                "{}",
10260                path.display()
10261            );
10262        }
10263
10264        // A surviving policy that names a different root does not get to have
10265        // its disagreement resolved by deleting something.
10266        let elsewhere = tempfile::tempdir().unwrap();
10267        let other =
10268            LocalAbsolutePath::new(elsewhere.path().to_str().unwrap()).expect("a second root");
10269        assert_eq!(
10270            verify_journalled_slot(&slot, nz(1), Some(&other))
10271                .unwrap_err()
10272                .refusal,
10273            SlotRefusal::PolicyRootDisagrees
10274        );
10275    }
10276
10277    #[cfg(unix)]
10278    #[test]
10279    fn a_substituted_work_directory_quarantines_the_slot_and_deletes_nothing_outside_it() {
10280        // Windows needs a privilege to create a junction or a symlink, so the
10281        // substitution is made here; the rule is platform-independent because it
10282        // is `symlink_metadata`'s answer plus the reparse attribute.
10283        let root = tempfile::tempdir().unwrap();
10284        let outside = root.path().join("operator-data");
10285        fs::create_dir(&outside).unwrap();
10286        let sentinel = outside.join("do-not-delete.txt");
10287        fs::write(
10288            &sentinel,
10289            b"an operator's data, outside every approved root",
10290        )
10291        .unwrap();
10292
10293        let slot = root.path().join("s1");
10294        fs::create_dir(&slot).unwrap();
10295        fs::create_dir(slot.join("bin")).unwrap();
10296        std::os::unix::fs::symlink(&outside, slot.join(DEFAULT_WORK_FOLDER)).unwrap();
10297
10298        let quarantine = scrub_slot_entries(&slot).unwrap_err();
10299        assert_eq!(quarantine.refusal, SlotRefusal::WorkNotADirectory);
10300        assert!(
10301            sentinel.exists(),
10302            "the deletion followed the link out of the slot"
10303        );
10304        assert!(outside.is_dir());
10305        assert!(
10306            slot.join(DEFAULT_WORK_FOLDER).symlink_metadata().is_ok(),
10307            "the substituted link is left for the operator, never unlinked as if it were ours"
10308        );
10309
10310        // A `_work` that is a plain file is the same refusal for the same
10311        // reason: it is not a job workspace, and this is not the code that
10312        // decides what to do about it.
10313        let file_work = root.path().join("s2");
10314        fs::create_dir(&file_work).unwrap();
10315        fs::write(file_work.join(DEFAULT_WORK_FOLDER), b"not a directory").unwrap();
10316        assert_eq!(
10317            scrub_slot_entries(&file_work).unwrap_err().refusal,
10318            SlotRefusal::WorkNotADirectory
10319        );
10320    }
10321
10322    #[cfg(unix)]
10323    #[test]
10324    fn a_slot_replaced_by_a_link_out_of_its_root_is_refused_before_anything_is_read() {
10325        let root = tempfile::tempdir().unwrap();
10326        let outside = root.path().join("operator-data");
10327        fs::create_dir(&outside).unwrap();
10328        let sentinel = outside.join("do-not-delete.txt");
10329        fs::write(
10330            &sentinel,
10331            b"an operator's data, outside every approved root",
10332        )
10333        .unwrap();
10334
10335        // The lexical half of containment passes -- the name is right and the
10336        // parent is right -- and canonical resolution is what catches it.
10337        let inside = root.path().join("inside");
10338        fs::create_dir(&inside).unwrap();
10339        let slot = inside.join("s1");
10340        std::os::unix::fs::symlink(&outside, &slot).unwrap();
10341
10342        assert_eq!(
10343            verify_journalled_slot(&slot, nz(1), None)
10344                .unwrap_err()
10345                .refusal,
10346            SlotRefusal::Containment
10347        );
10348        assert!(sentinel.exists());
10349        assert!(
10350            slot.symlink_metadata().is_ok(),
10351            "the link is left for the operator rather than removed as if it were ours"
10352        );
10353    }
10354
10355    /// The Windows half of the case above.
10356    ///
10357    /// Containment was proven only on Unix, and a symbolic link is the wrong
10358    /// instrument to prove it with on Windows: creating one needs a privilege an
10359    /// ordinary workflow does not have, so it is not the substitution an
10360    /// attacker would reach for. A **junction** needs none, which makes it both
10361    /// the realistic attack and the one this repository must refuse -- and
10362    /// `04-security-recovery.md` names it in the same breath as the symlink for
10363    /// exactly that reason.
10364    #[cfg(windows)]
10365    #[test]
10366    fn a_slot_root_replaced_by_a_junction_is_refused_before_anything_is_read() {
10367        let root = tempfile::tempdir().unwrap();
10368        let outside = root.path().join("operator-data");
10369        fs::create_dir(&outside).unwrap();
10370        let sentinel = outside.join("do-not-delete.txt");
10371        fs::write(
10372            &sentinel,
10373            b"an operator's data, outside every approved root",
10374        )
10375        .unwrap();
10376
10377        // The lexical half of containment passes -- `s1` under the root the
10378        // journal names -- and canonical resolution is what catches it.
10379        let inside = root.path().join("inside");
10380        fs::create_dir(&inside).unwrap();
10381        let slot = inside.join("s1");
10382        let Some(()) = plant_junction(&slot, &outside) else {
10383            eprintln!("skipped: this machine would not create a directory junction");
10384            return;
10385        };
10386
10387        assert_eq!(
10388            verify_journalled_slot(&slot, nz(1), None)
10389                .unwrap_err()
10390                .refusal,
10391            SlotRefusal::Containment
10392        );
10393        assert!(
10394            sentinel.exists(),
10395            "the refusal resolved the junction and reached the operator's data"
10396        );
10397        assert!(
10398            slot.symlink_metadata().is_ok(),
10399            "the junction is left for the operator rather than removed as if it were ours"
10400        );
10401    }
10402
10403    /// Plant a directory junction at `link` pointing at `target`.
10404    ///
10405    /// A junction is the Windows substitution this has to refuse, and unlike a
10406    /// symbolic link it needs no privilege — which is exactly why it is the one
10407    /// an unprivileged workflow would reach for. `mklink` is a `cmd` builtin, so
10408    /// there is no binary to find and nothing to install; `None` means this
10409    /// machine would not make one and the caller says so rather than asserting
10410    /// nothing.
10411    #[cfg(windows)]
10412    fn plant_junction(link: &Path, target: &Path) -> Option<()> {
10413        let made = std::process::Command::new("cmd")
10414            .arg("/C")
10415            .arg("mklink")
10416            .arg("/J")
10417            .arg(link)
10418            .arg(target)
10419            .output()
10420            .ok()?;
10421        (made.status.success() && link.symlink_metadata().is_ok()).then_some(())
10422    }
10423
10424    /// The dependency caches live beside attempts, under `<runner root>/_cache`,
10425    /// and jobs link into them (pnpm hard-links from its store; a workflow may
10426    /// symlink or junction a cache into its workspace). Removing an ephemeral
10427    /// attempt must take the links and never what they point at.
10428    #[test]
10429    fn removing_an_attempt_never_reaches_into_the_dependency_caches_it_linked() {
10430        let root = tempfile::tempdir().unwrap();
10431        let cache = root.path().join("_cache").join("o").join("r").join("npm");
10432        fs::create_dir_all(&cache).unwrap();
10433        let cached = cache.join("package.tgz");
10434        fs::write(&cached, b"kept between jobs").unwrap();
10435        let attempt = root.path().join("0123456789abcdef");
10436        let work = attempt.join("_work").join("repo");
10437        fs::create_dir_all(&work).unwrap();
10438        fs::hard_link(&cached, work.join("hard-linked.tgz")).unwrap();
10439        #[cfg(unix)]
10440        std::os::unix::fs::symlink(&cache, work.join("linked-cache")).unwrap();
10441        #[cfg(windows)]
10442        if plant_junction(&work.join("linked-cache"), &cache).is_none() {
10443            eprintln!("this machine would not create a junction; the hard link is still checked");
10444        }
10445
10446        remove_runtime_tree(&attempt).unwrap();
10447        assert!(!attempt.exists());
10448        assert_eq!(fs::read(&cached).unwrap(), b"kept between jobs");
10449    }
10450
10451    #[cfg(windows)]
10452    #[test]
10453    fn a_work_directory_replaced_by_a_junction_fails_closed_and_deletes_nothing_beyond_it() {
10454        let root = tempfile::tempdir().unwrap();
10455        let outside = root.path().join("operator-data");
10456        fs::create_dir(&outside).unwrap();
10457        let sentinel = outside.join("do-not-delete.txt");
10458        fs::write(
10459            &sentinel,
10460            b"an operator's data, outside every approved root",
10461        )
10462        .unwrap();
10463
10464        let slot = root.path().join("s1");
10465        fs::create_dir(&slot).unwrap();
10466        fs::create_dir(slot.join("bin")).unwrap();
10467        let Some(()) = plant_junction(&slot.join(DEFAULT_WORK_FOLDER), &outside) else {
10468            eprintln!("skipped: this machine would not create a directory junction");
10469            return;
10470        };
10471
10472        // The reparse point is what `is_link_like` answers on, so a junction is
10473        // refused for the same reason a symbolic link is and neither is
10474        // descended into.
10475        let work = fs::symlink_metadata(slot.join(DEFAULT_WORK_FOLDER)).unwrap();
10476        assert!(is_link_like(&work), "a junction is a reparse point");
10477        let quarantine = scrub_slot_entries(&slot).unwrap_err();
10478        assert_eq!(quarantine.refusal, SlotRefusal::WorkNotADirectory);
10479        assert!(
10480            sentinel.exists(),
10481            "the deletion followed the junction out of the slot"
10482        );
10483        assert!(outside.is_dir());
10484
10485        // A junction standing where an ordinary entry was is unlinked rather
10486        // than followed, so the removal still cannot reach through it.
10487        let elsewhere = root.path().join("s2");
10488        fs::create_dir(&elsewhere).unwrap();
10489        fs::create_dir(elsewhere.join(DEFAULT_WORK_FOLDER)).unwrap();
10490        if plant_junction(&elsewhere.join("externals"), &outside).is_some() {
10491            scrub_slot_entries(&elsewhere).expect("an ordinary entry is removed, junction or not");
10492            verify_slot_scrubbed(&elsewhere).expect("and the slot verifies");
10493            assert!(sentinel.exists(), "the junction was followed, not unlinked");
10494            assert_eq!(entries_of(&elsewhere), only_the_job_workspace());
10495        }
10496    }
10497
10498    #[cfg(unix)]
10499    #[tokio::test]
10500    async fn a_substituted_work_directory_leaves_the_attempt_uncleaned_and_still_leased() {
10501        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
10502            .with_persistent_workspace(2);
10503        harness.ready().await;
10504        let first = harness.launch().await;
10505        let slot = harness.slot_path(1);
10506        harness.conclude(first.id);
10507
10508        let outside = harness._root.path().join("operator-data");
10509        fs::create_dir_all(&outside).unwrap();
10510        let sentinel = outside.join("do-not-delete.txt");
10511        fs::write(&sentinel, b"outside every approved root").unwrap();
10512        std::os::unix::fs::symlink(&outside, slot.join(DEFAULT_WORK_FOLDER)).unwrap();
10513
10514        harness
10515            .launcher
10516            .clean(first.id)
10517            .await
10518            .expect_err("a slot whose `_work` was substituted is quarantined");
10519        assert!(sentinel.exists());
10520
10521        let held = harness.attempt(first.id);
10522        assert_eq!(held.state(), AttemptState::Failed);
10523        assert!(held.holds_slot_lease());
10524
10525        // The quarantined slot is not silently chosen again.
10526        let second = harness.launch().await;
10527        assert_eq!(second.workspace(), AttemptWorkspace::persistent_slot(nz(2)));
10528    }
10529
10530    #[test]
10531    fn a_slot_that_is_a_file_is_refused_and_a_slot_that_is_gone_is_not() {
10532        let root = tempfile::tempdir().unwrap();
10533        let occupied = root.path().join("s1");
10534        fs::write(&occupied, b"an operator's file").unwrap();
10535        // The journal check passes -- it is the right name under the right root
10536        // -- and the shape check is what refuses.
10537        verify_journalled_slot(&occupied, nz(1), None).expect("the path is the journalled slot");
10538        assert_eq!(
10539            slot_is_present(&occupied).unwrap_err().refusal,
10540            SlotRefusal::SlotNotADirectory
10541        );
10542        assert_eq!(fs::read_to_string(&occupied).unwrap(), "an operator's file");
10543
10544        // A directory that is simply not there leaves nothing to remove and
10545        // nothing to prove absent, so it is not a refusal.
10546        assert!(!slot_is_present(&root.path().join("s2")).unwrap());
10547        let present = root.path().join("s3");
10548        fs::create_dir(&present).unwrap();
10549        assert!(slot_is_present(&present).unwrap());
10550    }
10551
10552    #[test]
10553    fn cleanup_dispatches_on_the_journalled_kind_and_not_on_what_the_directory_holds() {
10554        let root = tempfile::tempdir().unwrap();
10555
10556        // A disposable directory that happens to contain a `_work` still goes
10557        // whole: the workspace kind is immutable so that the shape of a
10558        // directory a workflow can write to cannot choose its own algorithm.
10559        let disposable = root.path().join("abcdef012345");
10560        fs::create_dir_all(disposable.join(DEFAULT_WORK_FOLDER).join("repo")).unwrap();
10561        let ephemeral = RunnerAttempt::allocate(
10562            AttemptId::new_random(),
10563            fixtures::POLICY_ID,
10564            &disposable,
10565            fixtures::created_at(),
10566        );
10567        remove_materialized_package(&ephemeral).unwrap();
10568        assert!(!disposable.exists());
10569
10570        // And a slot keeps its `_work` with the same contents beneath it.
10571        let slot = root.path().join("s1");
10572        fs::create_dir_all(slot.join(DEFAULT_WORK_FOLDER).join("repo")).unwrap();
10573        fs::create_dir_all(slot.join("bin")).unwrap();
10574        let persistent = RunnerAttempt::allocate_in(
10575            AttemptId::new_random(),
10576            fixtures::POLICY_ID,
10577            &slot,
10578            AttemptWorkspace::persistent_slot(nz(1)),
10579            fixtures::created_at(),
10580        );
10581        remove_materialized_package(&persistent).unwrap();
10582        assert_eq!(entries_of(&slot), only_the_job_workspace());
10583        assert!(slot.join(DEFAULT_WORK_FOLDER).join("repo").is_dir());
10584    }
10585
10586    #[test]
10587    fn every_slot_refusal_names_a_distinct_event_class_and_keeps_the_lease() {
10588        let refusals = [
10589            SlotRefusal::NotTheJournalledSlot,
10590            SlotRefusal::PolicyRootDisagrees,
10591            SlotRefusal::Containment,
10592            SlotRefusal::SlotNotADirectory,
10593            SlotRefusal::Enumeration,
10594            SlotRefusal::WorkNotADirectory,
10595            SlotRefusal::Deletion,
10596            SlotRefusal::Residue,
10597        ];
10598        let classes: BTreeSet<&str> = refusals.iter().map(|refusal| refusal.class()).collect();
10599        assert_eq!(
10600            classes.len(),
10601            refusals.len(),
10602            "an event class shared by two refusals tells an operator less than it appears to"
10603        );
10604        for refusal in refusals {
10605            // The event field is a closed vocabulary, so it has to look like
10606            // one: `d1`'s sink allows the name verbatim.
10607            assert!(
10608                refusal
10609                    .class()
10610                    .chars()
10611                    .all(|c| c.is_ascii_lowercase() || c == '_'),
10612                "{}",
10613                refusal.class()
10614            );
10615            assert!(
10616                refusal.remediation().contains("slot lease"),
10617                "every refusal has to say the lease is still held: {}",
10618                refusal.class()
10619            );
10620        }
10621    }
10622
10623    #[test]
10624    fn copy_package_tree_copies_files_and_preserves_paths_with_spaces() {
10625        let root = tempfile::tempdir().unwrap();
10626        let source = root.path().join("source with spaces");
10627        let dest = root.path().join("dest with spaces");
10628
10629        fs::create_dir_all(&source).unwrap();
10630        fs::write(source.join("file1.txt"), b"hello").unwrap();
10631
10632        let nested = source.join("nested dir");
10633        fs::create_dir_all(&nested).unwrap();
10634        fs::write(nested.join("file2.txt"), b"world").unwrap();
10635
10636        // This is not a top-level `_work`, so it should be allowed
10637        let nested_work = nested.join(DEFAULT_WORK_FOLDER);
10638        fs::create_dir_all(&nested_work).unwrap();
10639        fs::write(nested_work.join("allowed.txt"), b"allowed").unwrap();
10640
10641        copy_package_tree(&source, &dest, &TrustedTrees::default()).unwrap();
10642
10643        assert_eq!(fs::read_to_string(dest.join("file1.txt")).unwrap(), "hello");
10644        assert_eq!(
10645            fs::read_to_string(dest.join("nested dir").join("file2.txt")).unwrap(),
10646            "world"
10647        );
10648        assert_eq!(
10649            fs::read_to_string(
10650                dest.join("nested dir")
10651                    .join(DEFAULT_WORK_FOLDER)
10652                    .join("allowed.txt")
10653            )
10654            .unwrap(),
10655            "allowed"
10656        );
10657    }
10658
10659    #[test]
10660    fn every_field_name_the_attempt_sink_emits_is_allowed() {
10661        use runner_manager_platform::logging::is_field_allowed;
10662
10663        // Kept beside the sink rather than derived from it, as in `reconcile`.
10664        for field in [
10665            "event",
10666            "attempt_id",
10667            "reason",
10668            "retry_in_ms",
10669            "duration_ms",
10670            "lock_wait_ms",
10671            "materialize_ms",
10672            "register_ms",
10673            "spawn_ms",
10674        ] {
10675            assert!(
10676                is_field_allowed(field),
10677                "`{field}` would be logged as `[redacted]`"
10678            );
10679        }
10680    }
10681
10682    #[cfg(unix)]
10683    fn package_entry(root: &Path, version: &str, manifest: &str) -> PathBuf {
10684        let entry = root.join("packages").join(version);
10685        fs::create_dir_all(entry.join("bin")).unwrap();
10686        fs::write(entry.join(crate::package::MANIFEST_FILE), manifest).unwrap();
10687        fs::write(entry.join("bin").join("Runner.Listener"), version).unwrap();
10688        entry
10689    }
10690
10691    /// The seed's binary's inode: a rebuilt seed holds a new file.
10692    #[cfg(unix)]
10693    fn listener_inode(seed: &Path) -> u64 {
10694        use std::os::unix::fs::MetadataExt as _;
10695        fs::metadata(seed.join("bin").join("Runner.Listener"))
10696            .unwrap()
10697            .ino()
10698    }
10699
10700    #[cfg(unix)]
10701    #[test]
10702    fn a_package_seed_is_built_once_and_reused_while_untouched() {
10703        let root = tempfile::tempdir().unwrap();
10704        let source = package_entry(root.path(), "2.336.0", "{\"digest\":\"a\"}");
10705        let seeds = root.path().join("runners").join(PACKAGE_SEED_DIR);
10706        let trusted = TrustedTrees::default();
10707
10708        let seed = refresh_package_seed(&source, &seeds, &trusted).unwrap();
10709        assert_eq!(seed, seeds.join("2.336.0"));
10710        assert_eq!(
10711            fs::read_to_string(seed.join("bin").join("Runner.Listener")).unwrap(),
10712            "2.336.0"
10713        );
10714
10715        let built = listener_inode(&seed);
10716        assert_eq!(
10717            refresh_package_seed(&source, &seeds, &trusted).unwrap(),
10718            seed
10719        );
10720        assert_eq!(listener_inode(&seed), built, "an untouched seed is reused");
10721    }
10722
10723    #[cfg(unix)]
10724    #[test]
10725    fn a_tampered_or_unrecorded_seed_is_rebuilt() {
10726        let root = tempfile::tempdir().unwrap();
10727        let source = package_entry(root.path(), "2.336.0", "{\"digest\":\"a\"}");
10728        let seeds = root.path().join("runners").join(PACKAGE_SEED_DIR);
10729        let trusted = TrustedTrees::default();
10730        let seed = refresh_package_seed(&source, &seeds, &trusted).unwrap();
10731
10732        // A job rewrites the binary in place and leaves the manifest alone.
10733        fs::write(seed.join("bin").join("Runner.Listener"), b"planted").unwrap();
10734        refresh_package_seed(&source, &seeds, &trusted).unwrap();
10735        assert_eq!(
10736            fs::read_to_string(seed.join("bin").join("Runner.Listener")).unwrap(),
10737            "2.336.0",
10738            "a seed whose print moved is rebuilt from the cache entry"
10739        );
10740
10741        // A restarted daemon has no print of a seed it did not build.
10742        // Keep the old inode allocated until the replacement is complete;
10743        // otherwise the filesystem may immediately reuse its number.
10744        let old_listener = fs::File::open(seed.join("bin").join("Runner.Listener")).unwrap();
10745        let built = listener_inode(&seed);
10746        refresh_package_seed(&source, &seeds, &TrustedTrees::default()).unwrap();
10747        assert_ne!(
10748            listener_inode(&seed),
10749            built,
10750            "an unrecorded seed is rebuilt"
10751        );
10752        drop(old_listener);
10753    }
10754
10755    #[cfg(unix)]
10756    #[test]
10757    fn a_package_seed_is_rebuilt_for_a_new_entry_and_drops_every_other() {
10758        let root = tempfile::tempdir().unwrap();
10759        let seeds = root.path().join("runners").join(PACKAGE_SEED_DIR);
10760        let trusted = TrustedTrees::default();
10761        let old = package_entry(root.path(), "2.335.0", "{\"digest\":\"old\"}");
10762        refresh_package_seed(&old, &seeds, &trusted).unwrap();
10763        fs::create_dir_all(seeds.join(".staging-interrupted")).unwrap();
10764        fs::write(seeds.join("stray"), b"").unwrap();
10765
10766        let new = package_entry(root.path(), "2.336.0", "{\"digest\":\"new\"}");
10767        let seed = refresh_package_seed(&new, &seeds, &trusted).unwrap();
10768
10769        let mut left: Vec<_> = fs::read_dir(&seeds)
10770            .unwrap()
10771            .map(|entry| entry.unwrap().file_name().into_string().unwrap())
10772            .collect();
10773        left.sort();
10774        assert_eq!(left, ["2.336.0"]);
10775
10776        // A reinstalled entry of the same version has a different manifest.
10777        fs::write(
10778            new.join(crate::package::MANIFEST_FILE),
10779            "{\"digest\":\"again\"}",
10780        )
10781        .unwrap();
10782        refresh_package_seed(&new, &seeds, &trusted).unwrap();
10783        assert_eq!(
10784            fs::read_to_string(seed.join(crate::package::MANIFEST_FILE)).unwrap(),
10785            "{\"digest\":\"again\"}",
10786            "a stale seed is rebuilt from the changed entry"
10787        );
10788    }
10789
10790    #[cfg(unix)]
10791    #[test]
10792    fn a_package_on_the_runtime_volume_is_its_own_seed() {
10793        let root = tempfile::tempdir().unwrap();
10794        let source = package_entry(root.path(), "2.336.0", "{}");
10795        let runtime = root.path().join("runners").join("attempt");
10796        fs::create_dir_all(&runtime).unwrap();
10797
10798        let trusted = TrustedTrees::default();
10799        assert_eq!(package_seed(&source, &runtime, &trusted).unwrap(), source);
10800        copy_package_tree(&source, &runtime, &trusted).unwrap();
10801        assert!(runtime.join("bin").join("Runner.Listener").exists());
10802        assert!(!root.path().join("runners").join(PACKAGE_SEED_DIR).exists());
10803    }
10804
10805    #[test]
10806    fn copy_package_tree_refuses_top_level_work_folder() {
10807        let root = tempfile::tempdir().unwrap();
10808        let source = root.path().join("source");
10809        let dest = root.path().join("dest");
10810
10811        fs::create_dir_all(&source).unwrap();
10812        fs::write(source.join("file1.txt"), b"hello").unwrap();
10813
10814        // Top-level `_work` should be refused
10815        let top_work = source.join(DEFAULT_WORK_FOLDER);
10816        fs::create_dir_all(&top_work).unwrap();
10817
10818        let err = copy_package_tree(&source, &dest, &TrustedTrees::default()).unwrap_err();
10819        assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
10820    }
10821}