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    docker_cleanup: Option<crate::docker_cleanup::DockerCleanup>,
2101    #[cfg(test)]
2102    post_spawn_faults: Mutex<VecDeque<PostSpawnBoundary>>,
2103    #[cfg(test)]
2104    post_spawn_reaps: std::sync::atomic::AtomicUsize,
2105    #[cfg(test)]
2106    post_spawn_stop_failures: std::sync::atomic::AtomicUsize,
2107    #[cfg(test)]
2108    use_long_lived_test_listener: std::sync::atomic::AtomicBool,
2109}
2110
2111/// Production provider set. Native and isolated allocations are routed by the
2112/// immutable execution kind in the policy/journal; an unavailable isolated
2113/// backend can never fall through to native process launch.
2114#[derive(Debug)]
2115pub struct PlatformExecutionProvider {
2116    native: NativeProcesses,
2117    oci: OciProcesses,
2118    windows_hyper_v: WindowsHyperVContainers,
2119}
2120
2121impl PlatformExecutionProvider {
2122    #[must_use]
2123    pub fn new(host_id: HostId) -> Self {
2124        Self {
2125            native: NativeProcesses::new(),
2126            oci: OciProcesses::new(host_id),
2127            windows_hyper_v: WindowsHyperVContainers::new(host_id),
2128        }
2129    }
2130
2131    /// Applies the host's `runner.env` at `path` to every native runner.
2132    /// Isolated runners do not get it: their environment is the image's.
2133    #[must_use]
2134    pub fn with_runner_env_file(mut self, path: PathBuf) -> Self {
2135        self.native = self.native.with_runner_env_file(path);
2136        self
2137    }
2138
2139    /// Gives every native runner persistent dependency caches. Isolated
2140    /// runners do not get them: their providers mount no host directory.
2141    #[must_use]
2142    pub fn with_dependency_caches(mut self, caches: DependencyCaches) -> Self {
2143        self.native = self.native.with_dependency_caches(caches);
2144        self
2145    }
2146
2147    #[must_use]
2148    pub fn with_docker_cleanup(mut self, cleanup: crate::docker_cleanup::DockerCleanup) -> Self {
2149        self.native.docker_cleanup = Some(cleanup);
2150        self
2151    }
2152
2153    fn isolated_provider(&self, backend: Backend) -> Option<&dyn ExecutionProvider> {
2154        match backend {
2155            Backend::Auto => {
2156                #[cfg(target_os = "windows")]
2157                {
2158                    Some(&self.windows_hyper_v)
2159                }
2160                #[cfg(not(target_os = "windows"))]
2161                {
2162                    Some(&self.oci)
2163                }
2164            }
2165            Backend::Oci => Some(&self.oci),
2166            Backend::WindowsHyperVContainer => Some(&self.windows_hyper_v),
2167            Backend::VirtualMachine => None,
2168        }
2169    }
2170
2171    fn provider_for_attempt(&self, attempt: &RunnerAttempt) -> Option<&dyn ExecutionProvider> {
2172        match attempt.execution() {
2173            AttemptExecution::Native { .. } => Some(&self.native),
2174            AttemptExecution::Isolated { provider_kind, .. } => {
2175                self.isolated_provider(*provider_kind)
2176            }
2177        }
2178    }
2179
2180    fn required_provider_for_attempt(
2181        &self,
2182        attempt: &RunnerAttempt,
2183    ) -> Result<&dyn ExecutionProvider, FailureReason> {
2184        self.provider_for_attempt(attempt)
2185            .ok_or_else(|| FailureReason::Other("execution provider unavailable".into()))
2186    }
2187}
2188
2189impl ExecutionProvider for PlatformExecutionProvider {
2190    fn probe(&self, policy: &ScalePolicy) -> ProviderCapability {
2191        match policy.execution_policy() {
2192            runner_manager_domain::execution::ExecutionPolicy::Native => self.native.probe(policy),
2193            runner_manager_domain::execution::ExecutionPolicy::Isolated { backend, .. } => self
2194                .isolated_provider(*backend)
2195                .map_or(ProviderCapability::Unsupported, |provider| {
2196                    provider.probe(policy)
2197                }),
2198        }
2199    }
2200
2201    fn resolve(&self, policy: &ScalePolicy) -> Result<Option<ResolvedEnvironment>, FailureReason> {
2202        match policy.execution_policy() {
2203            runner_manager_domain::execution::ExecutionPolicy::Native => {
2204                self.native.resolve(policy)
2205            }
2206            runner_manager_domain::execution::ExecutionPolicy::Isolated { backend, .. } => self
2207                .isolated_provider(*backend)
2208                .ok_or_else(|| FailureReason::Other("execution provider unavailable".into()))?
2209                .resolve(policy),
2210        }
2211    }
2212
2213    fn prepare(
2214        &self,
2215        attempt: &RunnerAttempt,
2216        policy: &ScalePolicy,
2217    ) -> Result<PreparedEnvironment, FailureReason> {
2218        match policy.execution_policy() {
2219            runner_manager_domain::execution::ExecutionPolicy::Native => {
2220                self.native.prepare(attempt, policy)
2221            }
2222            runner_manager_domain::execution::ExecutionPolicy::Isolated { backend, .. } => self
2223                .isolated_provider(*backend)
2224                .ok_or_else(|| FailureReason::Other("execution provider unavailable".into()))?
2225                .prepare(attempt, policy),
2226        }
2227    }
2228
2229    fn start(
2230        &self,
2231        prepared: PreparedEnvironment,
2232        attempt: &RunnerAttempt,
2233        handoff: OneTimeJitHandoff<'_>,
2234    ) -> Result<EnvironmentIdentity, ProcessStartFailure> {
2235        self.provider_for_attempt(attempt)
2236            .ok_or_else(|| {
2237                ProcessStartFailure::before_spawn(FailureReason::Other(
2238                    "execution provider unavailable".into(),
2239                ))
2240            })?
2241            .start(prepared, attempt, handoff)
2242    }
2243
2244    fn inspect(&self, attempt: &RunnerAttempt) -> Result<EnvironmentState, FailureReason> {
2245        self.required_provider_for_attempt(attempt)?
2246            .inspect(attempt)
2247    }
2248
2249    fn stop(&self, attempt: &RunnerAttempt) -> Result<ProviderStop, FailureReason> {
2250        self.required_provider_for_attempt(attempt)?.stop(attempt)
2251    }
2252
2253    fn destroy(&self, attempt: &RunnerAttempt) -> Result<ProviderDestroy, FailureReason> {
2254        self.required_provider_for_attempt(attempt)?
2255            .destroy(attempt)
2256    }
2257
2258    fn recover(&self, attempt: &RunnerAttempt) -> Result<EnvironmentState, FailureReason> {
2259        self.required_provider_for_attempt(attempt)?
2260            .recover(attempt)
2261    }
2262
2263    fn enumerate_owned(&self, host_id: HostId) -> Vec<EnvironmentIdentity> {
2264        let mut owned = self.oci.enumerate_owned(host_id);
2265        owned.extend(self.windows_hyper_v.enumerate_owned(host_id));
2266        owned
2267    }
2268
2269    fn diagnostics(&self, attempt: &RunnerAttempt) -> Vec<ProviderDiagnostic> {
2270        self.provider_for_attempt(attempt)
2271            .map_or_else(Vec::new, |provider| provider.diagnostics(attempt))
2272    }
2273
2274    fn owns(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
2275        self.required_provider_for_attempt(attempt)?.owns(attempt)
2276    }
2277
2278    fn spawn(
2279        &self,
2280        attempt: &RunnerAttempt,
2281        config: &EncodedJitConfig,
2282    ) -> Result<u32, ProcessStartFailure> {
2283        if !attempt.execution().is_native() {
2284            return Err(ProcessStartFailure::before_spawn(FailureReason::Other(
2285                "native process launch refused for an isolated allocation".into(),
2286            )));
2287        }
2288        self.native.spawn(attempt, config)
2289    }
2290
2291    fn is_alive(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
2292        self.required_provider_for_attempt(attempt)?
2293            .is_alive(attempt)
2294    }
2295
2296    fn recovered_pid(&self, attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason> {
2297        self.required_provider_for_attempt(attempt)?
2298            .recovered_pid(attempt)
2299    }
2300
2301    fn completed_successfully(&self, attempt: &RunnerAttempt) -> bool {
2302        self.provider_for_attempt(attempt)
2303            .is_some_and(|provider| provider.completed_successfully(attempt))
2304    }
2305
2306    fn record_terminate_intent(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason> {
2307        self.required_provider_for_attempt(attempt)?
2308            .record_terminate_intent(attempt)
2309    }
2310
2311    fn has_terminate_intent(&self, attempt: &RunnerAttempt) -> bool {
2312        self.provider_for_attempt(attempt)
2313            .is_some_and(|provider| provider.has_terminate_intent(attempt))
2314    }
2315
2316    fn terminate(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason> {
2317        self.required_provider_for_attempt(attempt)?
2318            .terminate(attempt)
2319    }
2320}
2321
2322#[cfg(test)]
2323#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2324enum PostSpawnBoundary {
2325    HandoffDelete,
2326    IdentitySerialize,
2327    IdentityWrite,
2328    ChildMapInsert,
2329}
2330
2331impl NativeProcesses {
2332    #[must_use]
2333    pub fn new() -> Self {
2334        Self::default()
2335    }
2336
2337    /// Applies the host's `runner.env` at `path` to every runner launched.
2338    #[must_use]
2339    pub fn with_runner_env_file(mut self, path: PathBuf) -> Self {
2340        self.runner_env_file = Some(path);
2341        self
2342    }
2343
2344    /// Gives every runner launched persistent dependency caches.
2345    #[must_use]
2346    pub fn with_dependency_caches(mut self, caches: DependencyCaches) -> Self {
2347        self.dependency_caches = Some(caches);
2348        self
2349    }
2350
2351    /// The cache variables for `attempt`, when `prepare` named its target.
2352    fn cache_env(
2353        &self,
2354        attempt: &RunnerAttempt,
2355        target: Option<&runner_manager_domain::model::ScaleTarget>,
2356        host_env: &RunnerEnv,
2357    ) -> Vec<(&'static str, std::ffi::OsString)> {
2358        match (&self.dependency_caches, target) {
2359            (Some(caches), Some(target)) => {
2360                caches.for_launch(target, attempt.runtime_path(), host_env)
2361            }
2362            _ => Vec::new(),
2363        }
2364    }
2365
2366    /// Starts `Runner.Listener` for `attempt`, with the dependency caches of
2367    /// `cache_target` when there is one.
2368    fn spawn_listener(
2369        &self,
2370        attempt: &RunnerAttempt,
2371        config: &EncodedJitConfig,
2372        cache_target: Option<&runner_manager_domain::model::ScaleTarget>,
2373    ) -> Result<u32, ProcessStartFailure> {
2374        let handoff = RestrictiveHandoff::create(
2375            attempt.runtime_path(),
2376            SecretString::from(config.expose().to_owned()),
2377        )
2378        .map_err(|_| ProcessStartFailure::before_spawn(FailureReason::ProcessStartFailed))?;
2379        #[cfg(windows)]
2380        let program = attempt
2381            .runtime_path()
2382            .join("bin")
2383            .join("Runner.Listener.exe");
2384        #[cfg(not(windows))]
2385        let program = attempt.runtime_path().join("bin").join("Runner.Listener");
2386        // Checked after the handoff exists on purpose: the error path below is
2387        // a real post-handoff launch failure, and unwinding must delete it.
2388        if !program.is_file() {
2389            return Err(ProcessStartFailure::before_spawn(
2390                FailureReason::ProcessStartFailed,
2391            ));
2392        }
2393        let host_env = self.host_env().map_err(ProcessStartFailure::before_spawn)?;
2394        let caches = self.cache_env(attempt, cache_target, &host_env);
2395        #[cfg(test)]
2396        let spec = if self
2397            .use_long_lived_test_listener
2398            .load(std::sync::atomic::Ordering::SeqCst)
2399        {
2400            SpawnSpec::new(program)
2401                .args([
2402                    "--ignored",
2403                    "--exact",
2404                    "lifecycle::tests::long_lived_native_listener_helper",
2405                    "--nocapture",
2406                ])
2407                .env(
2408                    "RUNNER_MANAGER_TEST_LISTENER_READY",
2409                    attempt.runtime_path().join(TEST_LISTENER_READY),
2410                )
2411                .working_dir(attempt.runtime_path())
2412        } else {
2413            runner_listener_spec(program, attempt.runtime_path(), caches, &host_env)
2414        };
2415        #[cfg(not(test))]
2416        let spec = runner_listener_spec(program, attempt.runtime_path(), caches, &host_env);
2417        let (spec, docker_proxy) = if let Some(cleanup) = &self.docker_cleanup {
2418            let repository = cache_target.map(ToString::to_string).unwrap_or_default();
2419            match cleanup.launch_environment(attempt.id, &repository, &host_env) {
2420                Ok(Some(endpoint)) => (
2421                    spec.env("DOCKER_HOST", endpoint),
2422                    Some(cleanup.root.join(attempt.id.to_string())),
2423                ),
2424                Ok(None) => (spec, None),
2425                Err(error) => {
2426                    tracing::warn!(attempt_id = %attempt.id, event = "docker_accounting_unavailable", reason = ?error.kind(), "native Docker accounting could not start");
2427                    if crate::docker_cleanup::Config::load(&cleanup.config).map_or(true, |config| {
2428                        config.mode == crate::docker_cleanup::Mode::Apply
2429                    }) {
2430                        return Err(ProcessStartFailure::before_spawn(
2431                            FailureReason::ProcessStartFailed,
2432                        ));
2433                    }
2434                    (spec, None)
2435                }
2436            }
2437        } else {
2438            (spec, None)
2439        };
2440        let child = spec
2441            .spawn_runner_with_handoff(&handoff)
2442            .map_err(|_| ProcessStartFailure::before_spawn(FailureReason::ProcessStartFailed))?;
2443        // The payload is gone before any state saying "starting" is persisted.
2444        #[cfg(test)]
2445        if self.faults_at(PostSpawnBoundary::HandoffDelete) {
2446            drop(handoff);
2447            return Err(self.abort_spawned_child(child, attempt, false));
2448        }
2449        if handoff.delete().is_err() {
2450            return Err(self.abort_spawned_child(child, attempt, false));
2451        }
2452        #[cfg(test)]
2453        if self.faults_at(PostSpawnBoundary::IdentitySerialize) {
2454            return Err(self.abort_spawned_child(child, attempt, false));
2455        }
2456        let identity = match serde_json::to_vec(child.identity()) {
2457            Ok(identity) => identity,
2458            Err(_) => {
2459                return Err(self.abort_spawned_child(child, attempt, false));
2460            }
2461        };
2462        if self.persist_identity(attempt, &identity).is_err() {
2463            return Err(self.abort_spawned_child(child, attempt, true));
2464        }
2465        if let Some(directory) = docker_proxy
2466            && crate::docker_cleanup::write_json(&directory.join("runner.json"), child.identity())
2467                .is_err()
2468        {
2469            return Err(self.abort_spawned_child(child, attempt, true));
2470        }
2471        let pid = child.pid();
2472        #[cfg(test)]
2473        if self.faults_at(PostSpawnBoundary::ChildMapInsert) {
2474            return Err(self.abort_spawned_child(child, attempt, true));
2475        }
2476        let mut children = self
2477            .children
2478            .lock()
2479            .unwrap_or_else(std::sync::PoisonError::into_inner);
2480        children.insert(attempt.id, child);
2481        Ok(pid)
2482    }
2483
2484    /// The host's `runner.env`, read now.
2485    ///
2486    /// A file that cannot be read or parsed fails the launch rather than
2487    /// starting a runner without the environment the operator configured.
2488    /// [`ExecutionProvider::prepare`] asks first, before a JIT runner is
2489    /// registered, so a broken file costs no registration. The reason names
2490    /// the file and the line or variable, never a value.
2491    fn host_env(&self) -> Result<RunnerEnv, FailureReason> {
2492        let Some(path) = &self.runner_env_file else {
2493            return Ok(RunnerEnv::default());
2494        };
2495        RunnerEnv::load(path).map_err(|error| {
2496            tracing::warn!(
2497                reason = "runner_env_file_invalid",
2498                "runner.env could not be applied, so no runner starts until it is fixed: {error}"
2499            );
2500            FailureReason::Other(format!(
2501                "runner.env cannot be applied ({error}); fix it with `runner-manager host env`"
2502            ))
2503        })
2504    }
2505
2506    #[cfg(test)]
2507    fn fail_post_spawn_at(&self, boundary: PostSpawnBoundary) {
2508        self.post_spawn_faults.lock().unwrap().push_back(boundary);
2509    }
2510
2511    #[cfg(test)]
2512    fn faults_at(&self, boundary: PostSpawnBoundary) -> bool {
2513        let mut faults = self.post_spawn_faults.lock().unwrap();
2514        if faults.front() == Some(&boundary) {
2515            faults.pop_front();
2516            true
2517        } else {
2518            false
2519        }
2520    }
2521
2522    #[cfg(test)]
2523    fn fail_post_spawn_stops(&self, count: usize) {
2524        self.post_spawn_stop_failures
2525            .fetch_add(count, std::sync::atomic::Ordering::SeqCst);
2526    }
2527
2528    #[cfg(test)]
2529    fn fail_next_post_spawn_stop(&self) {
2530        self.fail_post_spawn_stops(1);
2531    }
2532
2533    #[cfg(test)]
2534    fn use_long_lived_test_listener(&self) {
2535        self.use_long_lived_test_listener
2536            .store(true, std::sync::atomic::Ordering::SeqCst);
2537    }
2538
2539    fn stop_spawned_child(&self, child: &mut ChildProcess) -> Result<(), FailureReason> {
2540        #[cfg(test)]
2541        if self
2542            .post_spawn_stop_failures
2543            .fetch_update(
2544                std::sync::atomic::Ordering::SeqCst,
2545                std::sync::atomic::Ordering::SeqCst,
2546                |left| if left > 0 { Some(left - 1) } else { None },
2547            )
2548            .is_ok()
2549        {
2550            return Err(FailureReason::Other("injected runner stop failure".into()));
2551        }
2552        child
2553            .stop(Duration::from_secs(1))
2554            .map(|_| ())
2555            .map_err(|_| FailureReason::Other("spawned runner process could not be stopped".into()))
2556    }
2557
2558    fn abort_spawned_child(
2559        &self,
2560        mut child: ChildProcess,
2561        attempt: &RunnerAttempt,
2562        remove_identity: bool,
2563    ) -> ProcessStartFailure {
2564        let mut reaped = self.stop_spawned_child(&mut child).is_ok();
2565        if reaped {
2566            if remove_identity {
2567                Self::remove_identity_files(attempt);
2568            }
2569        } else {
2570            // A failed stop is not a failed attempt yet.  Persist enough truth
2571            // for crash recovery, and retain the owned child when possible.
2572            let identity_durable =
2573                serde_json::to_vec(child.identity())
2574                    .ok()
2575                    .is_some_and(|identity| {
2576                        self.persist_identity(attempt, &identity).is_ok()
2577                            || self.persist_fallback_identity(attempt, &identity).is_ok()
2578                    });
2579            if !identity_durable {
2580                // Returning a live PID as though recovery were complete would
2581                // make the next boot trust a recyclable PID. Reaping is bounded;
2582                // if it cannot finish, the durable `starting` journal entry is
2583                // deliberately unresolved on restart and blocks new launches.
2584                for _ in 1..MAX_POST_SPAWN_STOP_ATTEMPTS {
2585                    if self.stop_spawned_child(&mut child).is_ok() {
2586                        reaped = true;
2587                        break;
2588                    }
2589                }
2590                if !reaped {
2591                    // The attempt journal will durably record `starting` and
2592                    // its PID. Recovery treats a missing full identity as
2593                    // unresolved and starts nothing, so bounded stop failure
2594                    // cannot turn into either a hang or a duplicate runner.
2595                    let pid = child.pid();
2596                    let marker = write_durable_file(
2597                        &Self::unresolved_process_path(attempt),
2598                        pid.to_string().as_bytes(),
2599                    );
2600                    self.children
2601                        .lock()
2602                        .unwrap_or_else(std::sync::PoisonError::into_inner)
2603                        .insert(attempt.id, child);
2604                    let reason = if marker.is_ok() {
2605                        FailureReason::Other(
2606                            "spawn cleanup exhausted its bounded stop attempts; the live process remains under durable unresolved supervision"
2607                                .into(),
2608                        )
2609                    } else {
2610                        FailureReason::Other(
2611                            "spawn cleanup exhausted its bounded stop attempts and the unresolved-process marker could not be journalled"
2612                                .into(),
2613                        )
2614                    };
2615                    return ProcessStartFailure::after_spawn_live_with_reason(pid, reason);
2616                }
2617            } else {
2618                let pid = child.pid();
2619                self.children
2620                    .lock()
2621                    .unwrap_or_else(std::sync::PoisonError::into_inner)
2622                    .insert(attempt.id, child);
2623                return ProcessStartFailure::after_spawn_live(pid);
2624            }
2625        }
2626        #[cfg(test)]
2627        if reaped {
2628            self.post_spawn_reaps
2629                .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2630        }
2631        #[cfg(not(test))]
2632        let _ = reaped;
2633        ProcessStartFailure::after_spawn_stopped()
2634    }
2635
2636    fn identity_path(attempt: &RunnerAttempt) -> PathBuf {
2637        attempt.runtime_path().join(IDENTITY_FILE)
2638    }
2639
2640    fn fallback_identity_path(attempt: &RunnerAttempt) -> PathBuf {
2641        attempt.runtime_path().join(FALLBACK_IDENTITY_FILE)
2642    }
2643
2644    fn unresolved_process_path(attempt: &RunnerAttempt) -> PathBuf {
2645        attempt.runtime_path().join(UNRESOLVED_PROCESS_FILE)
2646    }
2647
2648    fn remove_identity_files(attempt: &RunnerAttempt) {
2649        let _ = fs::remove_file(Self::identity_path(attempt));
2650        let _ = fs::remove_file(Self::fallback_identity_path(attempt));
2651        let _ = fs::remove_file(Self::unresolved_process_path(attempt));
2652    }
2653
2654    fn persist_identity(&self, attempt: &RunnerAttempt, bytes: &[u8]) -> std::io::Result<()> {
2655        self.persist_identity_at(&Self::identity_path(attempt), bytes)
2656    }
2657
2658    fn persist_fallback_identity(
2659        &self,
2660        attempt: &RunnerAttempt,
2661        bytes: &[u8],
2662    ) -> std::io::Result<()> {
2663        self.persist_identity_at(&Self::fallback_identity_path(attempt), bytes)
2664    }
2665
2666    fn persist_identity_at(&self, path: &Path, bytes: &[u8]) -> std::io::Result<()> {
2667        #[cfg(test)]
2668        if self.faults_at(PostSpawnBoundary::IdentityWrite) {
2669            return Err(std::io::Error::other("injected identity write failure"));
2670        }
2671        write_durable_file(path, bytes)
2672    }
2673
2674    fn intent_path(attempt: &RunnerAttempt) -> PathBuf {
2675        attempt.runtime_path().join(TERMINATE_INTENT_FILE)
2676    }
2677
2678    fn read_identity(attempt: &RunnerAttempt) -> Result<Option<ProcessIdentity>, FailureReason> {
2679        match Self::read_identity_at(&Self::identity_path(attempt))? {
2680            Some(identity) => Ok(Some(identity)),
2681            None => Self::read_identity_at(&Self::fallback_identity_path(attempt)),
2682        }
2683    }
2684
2685    fn read_identity_at(path: &Path) -> Result<Option<ProcessIdentity>, FailureReason> {
2686        match fs::read(path) {
2687            Ok(bytes) => serde_json::from_slice(&bytes)
2688                .map(Some)
2689                .map_err(|_| FailureReason::Other("process identity journal is unreadable".into())),
2690            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
2691            Err(_) => Err(FailureReason::Other(
2692                "process identity journal could not be read".into(),
2693            )),
2694        }
2695    }
2696}
2697
2698impl ProcessSupervisor for NativeProcesses {
2699    fn destroy(&self, attempt: &RunnerAttempt) -> Result<ProviderDestroy, FailureReason> {
2700        if !attempt.execution().is_native() {
2701            return Err(FailureReason::Other(
2702                "execution provider unavailable".into(),
2703            ));
2704        }
2705        // Existing native reconciliation has already settled process termination
2706        // before this boundary. Capture before ephemeral workspace deletion.
2707        if let Some(caches) = &self.dependency_caches {
2708            caches.capture_git(attempt.runtime_path());
2709        }
2710        Ok(ProviderDestroy::Destroyed)
2711    }
2712
2713    fn prepare(
2714        &self,
2715        attempt: &RunnerAttempt,
2716        policy: &ScalePolicy,
2717    ) -> Result<PreparedEnvironment, FailureReason> {
2718        if !attempt.execution().is_native() || !policy.execution_policy().is_native() {
2719            return Err(FailureReason::Other(
2720                "execution provider unavailable".into(),
2721            ));
2722        }
2723        self.host_env()?;
2724        // Asked here, before the JIT registration, for the reason `host_env`
2725        // is: a broken file must cost no registration.
2726        let cache_target = match &self.dependency_caches {
2727            Some(caches) => {
2728                caches.config()?;
2729                Some(policy.target.clone())
2730            }
2731            None => None,
2732        };
2733        Ok(PreparedEnvironment {
2734            attempt: attempt.id,
2735            identity: None,
2736            cache_target,
2737        })
2738    }
2739
2740    fn start(
2741        &self,
2742        prepared: PreparedEnvironment,
2743        attempt: &RunnerAttempt,
2744        handoff: OneTimeJitHandoff<'_>,
2745    ) -> Result<EnvironmentIdentity, ProcessStartFailure> {
2746        if prepared.attempt != attempt.id || !attempt.execution().is_native() {
2747            return Err(ProcessStartFailure::before_spawn(FailureReason::Other(
2748                "execution provider identity mismatch".into(),
2749            )));
2750        }
2751        self.spawn_listener(attempt, handoff.consume(), prepared.cache_target.as_ref())
2752            .map(EnvironmentIdentity::NativeProcess)
2753    }
2754
2755    fn spawn(
2756        &self,
2757        attempt: &RunnerAttempt,
2758        config: &EncodedJitConfig,
2759    ) -> Result<u32, ProcessStartFailure> {
2760        self.spawn_listener(attempt, config, None)
2761    }
2762
2763    fn is_alive(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
2764        let mut children = self
2765            .children
2766            .lock()
2767            .unwrap_or_else(std::sync::PoisonError::into_inner);
2768        if let Some(child) = children.get_mut(&attempt.id) {
2769            return match child
2770                .try_exit_status()
2771                .map_err(|_| FailureReason::Other("runner process could not be observed".into()))?
2772            {
2773                None => Ok(true),
2774                Some(status) => {
2775                    if let Ok(mut exits) = self.successful_exits.lock() {
2776                        exits.insert(attempt.id, status.success());
2777                    }
2778                    Ok(false)
2779                }
2780            };
2781        }
2782        let Some(identity) = Self::read_identity(attempt)? else {
2783            if attempt.process_id().is_some() || Self::unresolved_process_path(attempt).is_file() {
2784                return Err(FailureReason::Other(
2785                    "runner process identity is missing; refusing recovery until the process is resolved"
2786                        .into(),
2787                ));
2788            }
2789            return Ok(false);
2790        };
2791        match identity.recheck() {
2792            Ok(Adoption::Live) => Ok(true),
2793            Ok(Adoption::Gone | Adoption::PidRecycled { .. }) => Ok(false),
2794            Err(_) => Ok(false),
2795        }
2796    }
2797
2798    fn recovered_pid(&self, attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason> {
2799        Ok(Self::read_identity(attempt)?.map(|identity| identity.pid()))
2800    }
2801
2802    fn completed_successfully(&self, attempt: &RunnerAttempt) -> bool {
2803        self.successful_exits
2804            .lock()
2805            .ok()
2806            .and_then(|exits| exits.get(&attempt.id).copied())
2807            .unwrap_or(false)
2808    }
2809
2810    fn record_terminate_intent(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason> {
2811        let path = Self::intent_path(attempt);
2812        write_durable_file(&path, b"registration-timeout\n")
2813            .map_err(|_| FailureReason::Other("terminate intent could not be journalled".into()))
2814    }
2815
2816    fn has_terminate_intent(&self, attempt: &RunnerAttempt) -> bool {
2817        Self::intent_path(attempt).is_file()
2818    }
2819
2820    fn terminate(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason> {
2821        let mut children = self
2822            .children
2823            .lock()
2824            .unwrap_or_else(std::sync::PoisonError::into_inner);
2825        if let Some(child) = children.get_mut(&attempt.id) {
2826            child
2827                .stop(Duration::from_secs(10))
2828                .map_err(|_| FailureReason::Other("runner process could not be stopped".into()))?;
2829            return Ok(());
2830        }
2831        let Some(identity) = Self::read_identity(attempt)? else {
2832            return Ok(());
2833        };
2834        match identity
2835            .terminate(Duration::from_secs(10))
2836            .map_err(|_| FailureReason::Other("runner process could not be stopped".into()))?
2837        {
2838            Termination::Terminated | Termination::AlreadyGone => Ok(()),
2839            Termination::RefusedPidRecycled { .. } => Err(FailureReason::Other(
2840                "runner PID was recycled; refusing to signal it".into(),
2841            )),
2842        }
2843    }
2844}
2845
2846pub struct LifecyclePorts {
2847    pub store: Arc<dyn Store>,
2848    pub github: Arc<dyn LifecycleGithub>,
2849    pub packages: Arc<dyn RuntimePackages>,
2850    pub processes: Arc<dyn ExecutionProvider>,
2851    pub clock: Arc<dyn Clock>,
2852    pub demand: Arc<dyn DemandPersistence>,
2853    pub delay: Arc<dyn RetryDelay>,
2854    pub events: Arc<dyn AttemptEventSink>,
2855    pub reconcile_events: Arc<dyn EventSink>,
2856}
2857
2858impl fmt::Debug for LifecyclePorts {
2859    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2860        f.debug_struct("LifecyclePorts")
2861            .field("store", &self.store)
2862            .field("github", &self.github)
2863            .field("packages", &self.packages)
2864            .field("processes", &self.processes)
2865            .finish_non_exhaustive()
2866    }
2867}
2868
2869#[derive(Debug, thiserror::Error)]
2870pub enum LifecycleError {
2871    #[error("attempt journal operation failed")]
2872    Journal,
2873    #[error("attempt {0} is not in the journal")]
2874    Missing(AttemptId),
2875    #[error("attempt lifecycle transition was refused")]
2876    Transition,
2877    #[error("startup recovery has not completed")]
2878    RecoveryIncomplete,
2879    /// A persistent slot could not be proven safe to scrub, so the attempt keeps
2880    /// its uncleaned state and, with it, its slot lease.
2881    ///
2882    /// Separate from [`Self::Failed`] because the two demand opposite handling
2883    /// from the same call sites. A failure aborts the pass; a quarantine must
2884    /// not, or one stuck slot would stop the host launching anything at all —
2885    /// which is precisely what `04-security-recovery.md` rules out when it says
2886    /// such an attempt "does not count as active host capacity" and "recovery
2887    /// retries the same cleanup".
2888    #[error("the persistent slot was not cleaned: {detail}")]
2889    SlotQuarantined {
2890        /// The closed-vocabulary event field; never operator or workflow text.
2891        class: &'static str,
2892        detail: String,
2893    },
2894    /// An ephemeral runtime is still held by a late process. Its journal state
2895    /// remains unchanged and ordinary supervision retries it, but startup
2896    /// recovery must not take unrelated repositories offline because of it.
2897    #[error("{}", FailureReason::WorkspaceCleanupDeferred)]
2898    WorkspaceCleanupDeferred,
2899    #[error("isolated environment cleanup is deferred")]
2900    EnvironmentCleanupDeferred,
2901    #[error("runner lifecycle failed: {0}")]
2902    Failed(FailureReason),
2903}
2904
2905impl LifecycleError {
2906    fn reason(&self) -> FailureReason {
2907        match self {
2908            Self::Failed(reason) => reason.clone(),
2909            Self::RecoveryIncomplete => FailureReason::Other("startup recovery incomplete".into()),
2910            Self::Journal => FailureReason::Other("attempt journal operation failed".into()),
2911            Self::Missing(_) => FailureReason::Other("attempt disappeared from the journal".into()),
2912            Self::Transition => FailureReason::Other("attempt transition was refused".into()),
2913            // Rendered through `Display` rather than a second copy of the same
2914            // sentence, so the two cannot drift apart.
2915            Self::SlotQuarantined { .. } => FailureReason::Other(self.to_string()),
2916            Self::WorkspaceCleanupDeferred => FailureReason::WorkspaceCleanupDeferred,
2917            Self::EnvironmentCleanupDeferred => FailureReason::Other(self.to_string()),
2918        }
2919    }
2920}
2921
2922/// Production implementation of e1's launcher port.
2923#[derive(Debug)]
2924pub struct LifecycleLauncher {
2925    host_id: HostId,
2926    app_paths: runner_manager_platform::paths::AppPaths,
2927    diagnostics_root: PathBuf,
2928    runner_group_id: u64,
2929    timeouts: RecoveryTimeouts,
2930    retry: RetryPolicy,
2931    cancel: CancelToken,
2932    ports: LifecyclePorts,
2933    recovery_complete: Mutex<bool>,
2934    versions: Mutex<BTreeMap<AttemptId, RunnerVersion>>,
2935    pending_replacements: Mutex<BTreeMap<AttemptId, ReplacementIntent>>,
2936    /// Backoff for cleanups [`Self::supervise`] tolerates and retries, so a
2937    /// workspace that cannot be removed is not retried and reported on every
2938    /// pass.
2939    cleanup_retries: Mutex<CleanBackoff>,
2940}
2941
2942#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2943enum ReconcileProgress {
2944    Reconciled,
2945    Deferred,
2946    Replacement {
2947        attempt: AttemptId,
2948        operation: &'static str,
2949    },
2950}
2951
2952impl LifecycleLauncher {
2953    #[must_use]
2954    pub fn new(
2955        host_id: HostId,
2956        app_paths: runner_manager_platform::paths::AppPaths,
2957        diagnostics_root: impl Into<PathBuf>,
2958        runner_group_id: u64,
2959        timeouts: RecoveryTimeouts,
2960        retry: RetryPolicy,
2961        ports: LifecyclePorts,
2962    ) -> Self {
2963        Self {
2964            host_id,
2965            app_paths,
2966            diagnostics_root: diagnostics_root.into(),
2967            runner_group_id,
2968            timeouts,
2969            retry,
2970            cancel: CancelToken::new(),
2971            ports,
2972            recovery_complete: Mutex::new(false),
2973            versions: Mutex::new(BTreeMap::new()),
2974            pending_replacements: Mutex::new(BTreeMap::new()),
2975            cleanup_retries: Mutex::new(CleanBackoff::default()),
2976        }
2977    }
2978
2979    /// Reconcile the entire journal before allowing a launch.  Unknown-policy
2980    /// attempts are left untouched rather than being acted on without an
2981    /// ownership proof.
2982    pub async fn recover_startup(
2983        &self,
2984        policies: &[ScalePolicy],
2985    ) -> Result<Vec<ReplacementIntent>, LifecycleError> {
2986        *self
2987            .recovery_complete
2988            .lock()
2989            .map_err(|_| LifecycleError::Journal)? = false;
2990        let by_id: BTreeMap<_, _> = policies.iter().map(|policy| (policy.id, policy)).collect();
2991        let attempts = self
2992            .ports
2993            .store
2994            .attempts()
2995            .map_err(|_| LifecycleError::Journal)?;
2996        let owned = self.ports.processes.enumerate_owned(self.host_id);
2997        for identity in &owned {
2998            if let EnvironmentIdentity::Isolated { attempt: id, .. } = identity
2999                && !attempts.iter().any(|attempt| {
3000                    attempt.state() != AttemptState::Cleaned
3001                        && identity_matches_intent(self.host_id, attempt, identity)
3002                })
3003            {
3004                // Enumeration is discovery, not authority to delete.
3005                self.ports
3006                    .events
3007                    .emit(AttemptEvent::QuarantinedOwnedResource { attempt: *id });
3008                tracing::warn!(host_id = %self.host_id, attempt_id = %id, reason = "owned_isolated_resource_without_matching_journal", "provider resource quarantined for operator review");
3009            }
3010        }
3011        let mut unresolved = false;
3012        for mut attempt in attempts {
3013            let Some(policy) = by_id.get(&attempt.policy_id) else {
3014                if attempt.counts_against_capacity() {
3015                    unresolved = true;
3016                }
3017                continue;
3018            };
3019            authorize(self.host_id, policy, &attempt).map_err(|_| LifecycleError::Journal)?;
3020            let allocation_resources = owned
3021                .iter()
3022                .filter(|identity| identity_same_allocation(self.host_id, &attempt, identity))
3023                .collect::<Vec<_>>();
3024            if allocation_resources.len() > 1 {
3025                self.ports
3026                    .events
3027                    .emit(AttemptEvent::QuarantinedOwnedResource {
3028                        attempt: attempt.id,
3029                    });
3030                tracing::warn!(attempt_id = %attempt.id, reason = "duplicate_isolated_resource_ownership", "provider resources quarantined for operator review");
3031            }
3032            if attempt.state() != AttemptState::Cleaned
3033                && matches!(
3034                    attempt.execution(),
3035                    AttemptExecution::Isolated {
3036                        environment_id: None,
3037                        ..
3038                    }
3039                )
3040                && allocation_resources.len() == 1
3041                && let Some(EnvironmentIdentity::Isolated { environment_id, .. }) =
3042                    allocation_resources.first().copied()
3043            {
3044                attempt
3045                    .prepared_environment(environment_id.clone())
3046                    .map_err(|_| LifecycleError::Transition)?;
3047                if attempt.state() == AttemptState::Preparing {
3048                    attempt
3049                        .mark_prepared(self.ports.clock.now())
3050                        .map_err(|_| LifecycleError::Transition)?;
3051                }
3052                self.record(&attempt)?;
3053            }
3054            match self.reconcile_one(policy, attempt).await? {
3055                ReconcileProgress::Deferred => {
3056                    if policy.execution_policy().is_native() {
3057                        unresolved = true;
3058                    }
3059                }
3060                ReconcileProgress::Replacement { attempt, operation } => {
3061                    self.pending_replacements
3062                        .lock()
3063                        .map_err(|_| LifecycleError::Journal)?
3064                        .insert(
3065                            attempt,
3066                            ReplacementIntent {
3067                                policy: policy.id,
3068                                previous_attempt: attempt,
3069                                operation,
3070                            },
3071                        );
3072                }
3073                ReconcileProgress::Reconciled => {}
3074            }
3075        }
3076        if unresolved {
3077            return Err(LifecycleError::RecoveryIncomplete);
3078        }
3079        *self
3080            .recovery_complete
3081            .lock()
3082            .map_err(|_| LifecycleError::Journal)? = true;
3083        Ok(self
3084            .pending_replacements
3085            .lock()
3086            .map_err(|_| LifecycleError::Journal)?
3087            .values()
3088            .copied()
3089            .collect())
3090    }
3091
3092    /// Supervise all attempts of one policy during an ordinary poll.
3093    pub async fn supervise(
3094        &self,
3095        policy: &ScalePolicy,
3096    ) -> Result<Vec<ReplacementIntent>, LifecycleError> {
3097        let mut replacements = Vec::new();
3098        self.pending_replacements
3099            .lock()
3100            .map_err(|_| LifecycleError::Journal)?
3101            .retain(|_, intent| {
3102                if intent.policy == policy.id {
3103                    replacements.push(*intent);
3104                    false
3105                } else {
3106                    true
3107                }
3108            });
3109        let attempts = self
3110            .ports
3111            .store
3112            .attempts_for_policy(policy.id)
3113            .map_err(|_| LifecycleError::Journal)?;
3114        for attempt in attempts {
3115            authorize(self.host_id, policy, &attempt).map_err(|_| LifecycleError::Journal)?;
3116            if let ReconcileProgress::Replacement { attempt, operation } =
3117                self.reconcile_one(policy, attempt).await?
3118            {
3119                replacements.push(ReplacementIntent {
3120                    policy: policy.id,
3121                    previous_attempt: attempt,
3122                    operation,
3123                });
3124            }
3125        }
3126        Ok(replacements)
3127    }
3128
3129    async fn reconcile_one(
3130        &self,
3131        policy: &ScalePolicy,
3132        mut attempt: RunnerAttempt,
3133    ) -> Result<ReconcileProgress, LifecycleError> {
3134        if !attempt.execution().is_native() {
3135            return self.reconcile_isolated_one(policy, attempt).await;
3136        }
3137        if attempt.state() == AttemptState::Cleaned {
3138            // A child of Runner.Worker can outlive the runner process briefly.
3139            // If it recreates `_work` after `clean_attempt` removed the tree but
3140            // before the journal's final write, the journal truthfully reaches
3141            // `cleaned` and the directory appears again afterwards. Cleaned
3142            // attempts used to return here forever, making that late residue
3143            // invisible. The journalled ephemeral mode is the ownership proof:
3144            // retry the whole-tree removal, while a persistent slot deliberately
3145            // remains on disk with its retained `_work`.
3146            if matches!(attempt.workspace(), AttemptWorkspace::Ephemeral)
3147                && attempt.runtime_path().exists()
3148            {
3149                // The attempt is already terminal, has released its package
3150                // lease, and consumes no capacity. A late child may still hold
3151                // one of its recreated files open on Windows, so failure here
3152                // is residue to retry, not a reason to abort startup recovery
3153                // and take every unrelated repository offline.
3154                // Retried with backoff; the event is the one report, which the
3155                // event sink logs as a warning.
3156                let now = self.ports.clock.now();
3157                if !self.cleanup_retries().is_due(attempt.id, now) {
3158                    return Ok(ReconcileProgress::Reconciled);
3159                }
3160                match self.scrub_workspace(&attempt) {
3161                    Ok(()) => self.cleanup_retries().succeeded(attempt.id),
3162                    Err(LifecycleError::WorkspaceCleanupDeferred) => {
3163                        self.cleanup_retries().failed(attempt.id, now);
3164                        self.ports
3165                            .reconcile_events
3166                            .emit(LifecycleEvent::AttemptCleanFailed {
3167                                policy: attempt.policy_id,
3168                                attempt: attempt.id,
3169                                reason: "late_ephemeral_workspace_could_not_be_removed",
3170                            });
3171                    }
3172                    Err(error) => return Err(error),
3173                }
3174            }
3175            return Ok(ReconcileProgress::Reconciled);
3176        }
3177        if attempt.is_terminal() {
3178            self.clean_or_quarantine(&mut attempt)?;
3179            return Ok(ReconcileProgress::Reconciled);
3180        }
3181        let process_alive = self
3182            .ports
3183            .processes
3184            .inspect(&attempt)
3185            .map_err(LifecycleError::Failed)?
3186            == EnvironmentState::Running;
3187        let github = self
3188            .ports
3189            .github
3190            .observe(&policy.target, attempt.id, &self.cancel)
3191            .await;
3192
3193        // If the agent died after GitHub accepted the registration but before
3194        // the non-secret runner-id sidecar landed, inventory closes the gap.
3195        // Persist it before making any state decision so a second crash moves
3196        // the boundary forward rather than repeating it.
3197        if let Some(runner_id) = github.runner_id
3198            && read_runner_id(attempt.runtime_path()).is_none()
3199        {
3200            write_runner_id(attempt.runtime_path(), runner_id)?;
3201            self.ports
3202                .events
3203                .emit(AttemptEvent::RemoteIdentityRecovered {
3204                    attempt: attempt.id,
3205                    runner_id,
3206                });
3207        }
3208
3209        // The child identity is synced before `spawn` returns.  If the agent
3210        // crashed before the following `starting` journal write, recover that
3211        // exact PID and take the legal `jit_received -> starting` edge before
3212        // applying GitHub's authoritative idle/busy observation below.
3213        if attempt.state() == AttemptState::JitReceived
3214            && process_alive
3215            && let Some(pid) = self
3216                .ports
3217                .processes
3218                .recovered_pid(&attempt)
3219                .map_err(LifecycleError::Failed)?
3220        {
3221            attempt
3222                .started(pid, self.ports.clock.now())
3223                .map_err(|_| LifecycleError::Transition)?;
3224            self.record(&attempt)?;
3225        }
3226
3227        // What the runner's own diagnostics say about whether it ran a job.
3228        // Read before anything below can conclude the attempt, because cleanup
3229        // deletes `_diag` with the rest of the runtime directory.
3230        // A one-shot child owned by this invocation exited successfully. This
3231        // concludes the *runner attempt*, never the workflow outcome; GitHub
3232        // remains authoritative for that. GitHub's inventory is a sampled
3233        // witness: a job of a few seconds starts and ends between two passes,
3234        // and an assignment GitHub shows can be one the runner never took up.
3235        // The runner's own diagnostics decide, read before cleanup deletes
3236        // them, and GitHub's sample only decides when they say nothing. Not
3237        // while GitHub is unreachable: a registration still listed there could
3238        // not be removed, and nothing visits a concluded attempt again.
3239        if !process_alive
3240            && github.status != GithubRunnerObservation::Unreachable
3241            && matches!(
3242                attempt.state(),
3243                AttemptState::Starting | AttemptState::Idle | AttemptState::Busy
3244            )
3245            && self.ports.processes.completed_successfully(&attempt)
3246        {
3247            let state = attempt.state();
3248            let outcome = match (state, JobEvidence::of(attempt.runtime_path())) {
3249                // Without a runner id there is no edge to `busy`; the recovery
3250                // path below concludes the attempt without one.
3251                (AttemptState::Starting | AttemptState::Idle, JobEvidence::RanAJob) => {
3252                    known_runner_id(&attempt, github.runner_id)
3253                        .map(|runner_id| (Some(runner_id), AttemptOutcome::CompletedJob))
3254                }
3255                (AttemptState::Busy, JobEvidence::RanAJob) => {
3256                    Some((None, AttemptOutcome::CompletedJob))
3257                }
3258                (AttemptState::Busy, JobEvidence::NoJob) => {
3259                    Some((None, AttemptOutcome::ExitedIdleWithoutWork))
3260                }
3261                (AttemptState::Busy, JobEvidence::Unknown)
3262                    if github.status == GithubRunnerObservation::NotRegistered =>
3263                {
3264                    Some((None, AttemptOutcome::CompletedJob))
3265                }
3266                _ => None,
3267            };
3268            if let Some((walk_to_busy, outcome)) = outcome {
3269                // An ephemeral runner leaves GitHub's list a little after its
3270                // process exits; one still listed is removed here rather than
3271                // left for GitHub to time out. First, so the journal's last
3272                // intermediate state and the conclusion are written back to back:
3273                // an agent that dies between them recovers from the state it was
3274                // in before this pass.
3275                if matches!(github.status, GithubRunnerObservation::Registered { .. }) {
3276                    self.deregister_runner(policy, &attempt).await;
3277                }
3278                let now = self.ports.clock.now();
3279                match (walk_to_busy, &outcome) {
3280                    (Some(runner_id), _) => {
3281                        self.transition(&mut attempt, |a| a.assigned_job(runner_id, now))?;
3282                    }
3283                    (None, AttemptOutcome::ExitedIdleWithoutWork) => {
3284                        self.transition(&mut attempt, |a| a.assignment_not_taken(now))?;
3285                    }
3286                    (None, _) => {}
3287                }
3288                self.conclude(&mut attempt, outcome)?;
3289                self.clean_or_quarantine(&mut attempt)?;
3290                return Ok(ReconcileProgress::Reconciled);
3291            }
3292        }
3293
3294        // A live runner journalled `busy` that GitHub now shows waiting, and
3295        // whose diagnostics show no job: the assignment was never taken up.
3296        // Back to `idle`, where the idle timeout applies to it again; the
3297        // `busy` arm below would otherwise adopt it for as long as it runs.
3298        if process_alive
3299            && attempt.state() == AttemptState::Busy
3300            && github.status == (GithubRunnerObservation::Registered { busy: false })
3301            && JobEvidence::of(attempt.runtime_path()) == JobEvidence::NoJob
3302        {
3303            let now = self.ports.clock.now();
3304            self.transition(&mut attempt, |a| a.assignment_not_taken(now))?;
3305            return Ok(ReconcileProgress::Reconciled);
3306        }
3307
3308        // This durable mark is more authoritative than a later observation
3309        // which cannot distinguish an agent kill from a crash.
3310        if self.ports.processes.has_terminate_intent(&attempt) && !process_alive {
3311            self.deregister_runner(policy, &attempt).await;
3312            self.conclude(
3313                &mut attempt,
3314                AttemptOutcome::failed(FailureReason::TerminatedAfterRegistrationTimeout),
3315            )?;
3316            self.clean_or_quarantine(&mut attempt)?;
3317            return Ok(ReconcileProgress::Replacement {
3318                attempt: attempt.id,
3319                operation: "registration_timeout_replacement",
3320            });
3321        }
3322
3323        // A remote registration with no surviving process has lost its
3324        // one-shot JIT secret.  Walking it to `starting` would require inventing
3325        // a PID; retrying the same registration would require inventing the
3326        // secret.  Record the configuration as expired and let the bounded
3327        // replacement path request a fresh one only if demand remains.
3328        if matches!(
3329            attempt.state(),
3330            AttemptState::Allocated | AttemptState::JitReceived
3331        ) && !process_alive
3332            && matches!(github.status, GithubRunnerObservation::Registered { .. })
3333        {
3334            if attempt.state() == AttemptState::Allocated {
3335                attempt
3336                    .jit_received(self.ports.clock.now())
3337                    .map_err(|_| LifecycleError::Transition)?;
3338                self.record(&attempt)?;
3339            }
3340            self.deregister_runner(policy, &attempt).await;
3341            self.conclude(
3342                &mut attempt,
3343                AttemptOutcome::failed(FailureReason::JitExpired),
3344            )?;
3345            self.clean_or_quarantine(&mut attempt)?;
3346            return Ok(ReconcileProgress::Replacement {
3347                attempt: attempt.id,
3348                operation: "jit_expired_replacement",
3349            });
3350        }
3351
3352        match recovery_decision(
3353            &attempt,
3354            RecoveryObservation {
3355                process_alive,
3356                github: github.status,
3357            },
3358            self.timeouts,
3359            self.ports.clock.as_ref(),
3360        ) {
3361            RecoveryDecision::Nothing | RecoveryDecision::Wait => Ok(ReconcileProgress::Reconciled),
3362            RecoveryDecision::Defer => Ok(ReconcileProgress::Deferred),
3363            RecoveryDecision::Adopt => {
3364                self.ports.events.emit(AttemptEvent::Adopted {
3365                    attempt: attempt.id,
3366                });
3367                Ok(ReconcileProgress::Reconciled)
3368            }
3369            RecoveryDecision::Clean => {
3370                self.clean_or_quarantine(&mut attempt)?;
3371                Ok(ReconcileProgress::Reconciled)
3372            }
3373            RecoveryDecision::Observe(state) => {
3374                let runner_id = known_runner_id(&attempt, github.runner_id)
3375                    .ok_or(LifecycleError::Transition)?;
3376                match state {
3377                    AttemptState::JitReceived => attempt
3378                        .jit_received(self.ports.clock.now())
3379                        .map_err(|_| LifecycleError::Transition)?,
3380                    AttemptState::Starting => {
3381                        let pid = attempt.process_id().ok_or(LifecycleError::Transition)?;
3382                        attempt
3383                            .started(pid, self.ports.clock.now())
3384                            .map_err(|_| LifecycleError::Transition)?;
3385                    }
3386                    AttemptState::Idle => attempt
3387                        .registered_idle(runner_id, self.ports.clock.now())
3388                        .map_err(|_| LifecycleError::Transition)?,
3389                    AttemptState::Busy => attempt
3390                        .assigned_job(runner_id, self.ports.clock.now())
3391                        .map_err(|_| LifecycleError::Transition)?,
3392                    _ => return Err(LifecycleError::Transition),
3393                }
3394                self.record(&attempt)?;
3395                Ok(ReconcileProgress::Reconciled)
3396            }
3397            RecoveryDecision::Conclude(outcome) => {
3398                let replacement = replacement_operation(&outcome);
3399                // Only when GitHub still holds one. Every other conclusion here
3400                // was reached *because* the observation was `NotRegistered`, and
3401                // spending a DELETE to be told so again would put a request per
3402                // concluded attempt on a budget `rest.rs` prices to the request.
3403                if matches!(github.status, GithubRunnerObservation::Registered { .. }) {
3404                    self.deregister_runner(policy, &attempt).await;
3405                }
3406                self.conclude(&mut attempt, outcome)?;
3407                self.clean_or_quarantine(&mut attempt)?;
3408                Ok(
3409                    replacement.map_or(ReconcileProgress::Reconciled, |operation| {
3410                        ReconcileProgress::Replacement {
3411                            attempt: attempt.id,
3412                            operation,
3413                        }
3414                    }),
3415                )
3416            }
3417            RecoveryDecision::Terminate(payload) => {
3418                // The mark is synced first, and what proves the process died is
3419                // the `is_alive` re-read below -- not the outcome recorded after
3420                // it. Which outcome that is depends on why the termination was
3421                // ordered, and only the payload knows: a `starting` runner that
3422                // never registered is a failure this agent then stopped, while
3423                // an `idle` one past its timeout is flow 2.7's surplus exit and
3424                // no failure at all. Hardcoding the first reason here labelled
3425                // the second as a registration timeout and asked the allocator
3426                // for a replacement to boot.
3427                let idle_exit = payload.is_idle_exit();
3428                self.ports
3429                    .processes
3430                    .record_terminate_intent(&attempt)
3431                    .map_err(LifecycleError::Failed)?;
3432                self.ports.events.emit(AttemptEvent::TerminateIntent {
3433                    attempt: attempt.id,
3434                });
3435                if self
3436                    .ports
3437                    .processes
3438                    .stop(&attempt)
3439                    .map_err(LifecycleError::Failed)?
3440                    == ProviderStop::StillRunning
3441                {
3442                    return Ok(ReconcileProgress::Deferred);
3443                }
3444                if self
3445                    .ports
3446                    .processes
3447                    .inspect(&attempt)
3448                    .map_err(LifecycleError::Failed)?
3449                    == EnvironmentState::Running
3450                {
3451                    return Ok(ReconcileProgress::Deferred);
3452                }
3453                self.ports.events.emit(AttemptEvent::Terminated {
3454                    attempt: attempt.id,
3455                });
3456                // The registration-timeout path keeps deriving its own reason
3457                // rather than applying the payload: on the pass that reads the
3458                // journalled mark back the process is dead, and
3459                // `TerminatedAfterRegistrationTimeout` is the reason that stays
3460                // true of a dead process. See `RecoveryDecision::Terminate`.
3461                let outcome = if idle_exit {
3462                    AttemptOutcome::ExitedIdleWithoutWork
3463                } else {
3464                    AttemptOutcome::failed(FailureReason::TerminatedAfterRegistrationTimeout)
3465                };
3466                self.deregister_runner(policy, &attempt).await;
3467                self.conclude(&mut attempt, outcome)?;
3468                self.clean_or_quarantine(&mut attempt)?;
3469                // A surplus runner is not replaced. It was stopped precisely
3470                // because the work it was started for went elsewhere; asking the
3471                // allocator for another one rebuilds it every idle timeout.
3472                if idle_exit {
3473                    Ok(ReconcileProgress::Reconciled)
3474                } else {
3475                    Ok(ReconcileProgress::Replacement {
3476                        attempt: attempt.id,
3477                        operation: "registration_timeout_replacement",
3478                    })
3479                }
3480            }
3481        }
3482    }
3483
3484    async fn reconcile_isolated_one(
3485        &self,
3486        policy: &ScalePolicy,
3487        mut attempt: RunnerAttempt,
3488    ) -> Result<ReconcileProgress, LifecycleError> {
3489        if attempt.state() == AttemptState::Cleaned {
3490            return Ok(ReconcileProgress::Reconciled);
3491        }
3492        if attempt.is_terminal() {
3493            self.clean_or_quarantine(&mut attempt)?;
3494            return Ok(ReconcileProgress::Reconciled);
3495        }
3496        if matches!(
3497            attempt.state(),
3498            AttemptState::Allocated | AttemptState::Preparing | AttemptState::Prepared
3499        ) {
3500            // No JIT was requested yet. Restart can safely retire this attempt;
3501            // a matching owned resource is stopped by the durable cleanup path.
3502            self.conclude(
3503                &mut attempt,
3504                AttemptOutcome::failed(FailureReason::Other(
3505                    "isolated preparation interrupted".into(),
3506                )),
3507            )?;
3508            self.clean_or_quarantine(&mut attempt)?;
3509            return Ok(ReconcileProgress::Reconciled);
3510        }
3511        let Ok(provider_state) = self.ports.processes.recover(&attempt) else {
3512            return Ok(ReconcileProgress::Deferred);
3513        };
3514        if provider_state != EnvironmentState::Missing {
3515            let Ok(owned) = self.ports.processes.owns(&attempt) else {
3516                return Ok(ReconcileProgress::Deferred);
3517            };
3518            if !owned {
3519                self.ports
3520                    .events
3521                    .emit(AttemptEvent::QuarantinedOwnedResource {
3522                        attempt: attempt.id,
3523                    });
3524                tracing::warn!(attempt_id = %attempt.id, reason = "isolated_resource_ownership_mismatch", "provider resource quarantined for operator review");
3525                return Ok(ReconcileProgress::Deferred);
3526            }
3527        }
3528        let live = matches!(
3529            provider_state,
3530            EnvironmentState::Starting | EnvironmentState::Running
3531        );
3532        if attempt.state() == AttemptState::JitReceived && live {
3533            attempt
3534                .started_isolated(self.ports.clock.now())
3535                .map_err(|_| LifecycleError::Transition)?;
3536            self.record(&attempt)?;
3537        }
3538        let github = self
3539            .ports
3540            .github
3541            .observe(&policy.target, attempt.id, &self.cancel)
3542            .await;
3543        if let Some(runner_id) = github.runner_id
3544            && read_runner_id(attempt.runtime_path()).is_none()
3545        {
3546            write_runner_id(attempt.runtime_path(), runner_id)?;
3547        }
3548        // A successfully reaped one-shot listener is stronger evidence than a
3549        // missing registration. Recovered guests have no attached child and
3550        // still follow the conservative orphan path below.
3551        if attempt.state() == AttemptState::Busy
3552            && !live
3553            && github.status == GithubRunnerObservation::NotRegistered
3554            && self.ports.processes.completed_successfully(&attempt)
3555        {
3556            self.conclude(&mut attempt, AttemptOutcome::CompletedJob)?;
3557            self.clean_or_quarantine(&mut attempt)?;
3558            return Ok(ReconcileProgress::Reconciled);
3559        }
3560        match recovery_decision(
3561            &attempt,
3562            RecoveryObservation {
3563                process_alive: live,
3564                github: github.status,
3565            },
3566            self.timeouts,
3567            self.ports.clock.as_ref(),
3568        ) {
3569            RecoveryDecision::Nothing | RecoveryDecision::Wait => Ok(ReconcileProgress::Reconciled),
3570            RecoveryDecision::Defer => Ok(ReconcileProgress::Deferred),
3571            RecoveryDecision::Adopt => {
3572                self.ports.events.emit(AttemptEvent::Adopted {
3573                    attempt: attempt.id,
3574                });
3575                Ok(ReconcileProgress::Reconciled)
3576            }
3577            RecoveryDecision::Clean => {
3578                self.clean_or_quarantine(&mut attempt)?;
3579                Ok(ReconcileProgress::Reconciled)
3580            }
3581            RecoveryDecision::Observe(state) => {
3582                let runner_id = github
3583                    .runner_id
3584                    .or_else(|| read_runner_id(attempt.runtime_path()))
3585                    .ok_or(LifecycleError::Transition)?;
3586                match state {
3587                    AttemptState::Idle => attempt
3588                        .registered_idle(runner_id, self.ports.clock.now())
3589                        .map_err(|_| LifecycleError::Transition)?,
3590                    AttemptState::Busy => attempt
3591                        .assigned_job(runner_id, self.ports.clock.now())
3592                        .map_err(|_| LifecycleError::Transition)?,
3593                    _ => return Err(LifecycleError::Transition),
3594                }
3595                self.record(&attempt)?;
3596                Ok(ReconcileProgress::Reconciled)
3597            }
3598            RecoveryDecision::Conclude(outcome) | RecoveryDecision::Terminate(outcome) => {
3599                let replacement = replacement_operation(&outcome);
3600                if matches!(github.status, GithubRunnerObservation::Registered { .. }) {
3601                    self.deregister_runner(policy, &attempt).await;
3602                }
3603                self.conclude(&mut attempt, outcome)?;
3604                self.clean_or_quarantine(&mut attempt)?;
3605                Ok(
3606                    replacement.map_or(ReconcileProgress::Reconciled, |operation| {
3607                        ReconcileProgress::Replacement {
3608                            attempt: attempt.id,
3609                            operation,
3610                        }
3611                    }),
3612                )
3613            }
3614        }
3615    }
3616
3617    /// Takes one domain edge and journals it.
3618    fn transition(
3619        &self,
3620        attempt: &mut RunnerAttempt,
3621        edge: impl FnOnce(
3622            &mut RunnerAttempt,
3623        ) -> Result<(), runner_manager_domain::attempt::AttemptError>,
3624    ) -> Result<(), LifecycleError> {
3625        edge(attempt).map_err(|_| LifecycleError::Transition)?;
3626        self.record(attempt)
3627    }
3628
3629    fn record(&self, attempt: &RunnerAttempt) -> Result<(), LifecycleError> {
3630        self.ports
3631            .store
3632            .record_attempt(attempt)
3633            .map_err(|_| LifecycleError::Journal)?;
3634        self.ports.events.emit(AttemptEvent::State {
3635            attempt: attempt.id,
3636            state: attempt.state(),
3637        });
3638        Ok(())
3639    }
3640
3641    /// Remove the GitHub registration an attempt is about to leave behind.
3642    ///
3643    /// # Why this is not fallible, and does not block the conclusion
3644    ///
3645    /// GitHub retires an ephemeral runner itself once that runner *completes a
3646    /// job*, and for the ordinary path that is the whole story. The paths that
3647    /// reach here are the ones where it does not: a runner stopped before it
3648    /// was ever assigned work, a registration whose process died still holding
3649    /// it, a JIT configuration that expired. Nothing else deletes those, and
3650    /// before this existed nothing did — they accumulated in the target's
3651    /// runner settings, one row per attempt, for the life of the repository.
3652    ///
3653    /// It returns `()` rather than a `Result` because the alternative is worse
3654    /// in both directions. The attempt is over: its process is gone and its
3655    /// slot has to come back, so a failed delete may not abort the conclusion
3656    /// or the host leaks capacity every time GitHub is unreachable. And a
3657    /// registration that outlives this call is not lost — it is exactly the
3658    /// `Registered` + dead-process observation that
3659    /// [`AttemptOutcome::Orphaned`] already names, which a later pass can still
3660    /// see. So a failure is logged and stepped over, deliberately.
3661    async fn deregister_runner(&self, policy: &ScalePolicy, attempt: &RunnerAttempt) {
3662        let Some(runner_id) = attempt
3663            .github_runner_id()
3664            .or_else(|| read_runner_id(attempt.runtime_path()))
3665        else {
3666            return;
3667        };
3668        if self
3669            .ports
3670            .github
3671            .deregister(&policy.target, runner_id, &self.cancel)
3672            .await
3673        {
3674            self.ports.events.emit(AttemptEvent::Deregistered {
3675                attempt: attempt.id,
3676                runner_id,
3677            });
3678        } else {
3679            tracing::warn!(
3680                attempt = %attempt.id,
3681                runner_id,
3682                "the runner registration could not be removed from GitHub; it will show in the \
3683                 target's runner settings until GitHub retires it or a later pass removes it"
3684            );
3685        }
3686    }
3687
3688    fn conclude(
3689        &self,
3690        attempt: &mut RunnerAttempt,
3691        outcome: AttemptOutcome,
3692    ) -> Result<(), LifecycleError> {
3693        attempt
3694            .conclude(outcome.clone(), self.ports.clock.now())
3695            .map_err(|_| LifecycleError::Transition)?;
3696        self.record(attempt)?;
3697        self.ports.events.emit(AttemptEvent::Concluded {
3698            attempt: attempt.id,
3699            outcome: OutcomeKind::of(&outcome),
3700        });
3701        Ok(())
3702    }
3703
3704    /// Clean a concluded attempt, tolerating a quarantined persistent slot.
3705    ///
3706    /// The quarantine is reported and stepped over rather than raised, because
3707    /// raising it aborts the whole pass: on the startup path that leaves
3708    /// `recovery_complete` false and stops the host launching anything, which is
3709    /// the opposite of `04-security-recovery.md`'s "it does not count as active
3710    /// host capacity" and "recovery retries the same cleanup". The attempt keeps
3711    /// its state, so it keeps its slot lease and its directory, and the next
3712    /// pass — [`crate::reconcile::Reconciler`]'s terminal sweep on every poll,
3713    /// or the next startup — attempts exactly the same cleanup again.
3714    ///
3715    /// A persistent-slot quarantine and an ephemeral directory still held by a
3716    /// late process are tolerated. A journal failure or a package lease that
3717    /// cannot be released still propagates: those are not one workspace's
3718    /// problem.
3719    ///
3720    /// A tolerated failure is retried with per-attempt backoff: until it is
3721    /// due again the attempt is skipped silently rather than re-tried and
3722    /// re-reported on every pass.
3723    fn clean_or_quarantine(&self, attempt: &mut RunnerAttempt) -> Result<(), LifecycleError> {
3724        let now = self.ports.clock.now();
3725        if !self.cleanup_retries().is_due(attempt.id, now) {
3726            return Ok(());
3727        }
3728        let reason = match self.clean_attempt(attempt) {
3729            Err(LifecycleError::SlotQuarantined { class, .. }) => class,
3730            Err(LifecycleError::WorkspaceCleanupDeferred) => {
3731                failure_reason_kind(&FailureReason::WorkspaceCleanupDeferred)
3732            }
3733            Err(LifecycleError::EnvironmentCleanupDeferred) => {
3734                "isolated_environment_cleanup_deferred"
3735            }
3736            other => {
3737                if other.is_ok() {
3738                    self.cleanup_retries().succeeded(attempt.id);
3739                }
3740                return other;
3741            }
3742        };
3743        self.cleanup_retries().failed(attempt.id, now);
3744        self.ports
3745            .reconcile_events
3746            .emit(LifecycleEvent::AttemptCleanFailed {
3747                policy: attempt.policy_id,
3748                attempt: attempt.id,
3749                reason,
3750            });
3751        Ok(())
3752    }
3753
3754    fn cleanup_retries(&self) -> std::sync::MutexGuard<'_, CleanBackoff> {
3755        self.cleanup_retries
3756            .lock()
3757            .unwrap_or_else(std::sync::PoisonError::into_inner)
3758    }
3759
3760    fn clean_attempt(&self, attempt: &mut RunnerAttempt) -> Result<(), LifecycleError> {
3761        if !attempt.execution().is_native() {
3762            return self.clean_isolated_attempt(attempt);
3763        }
3764        match self
3765            .ports
3766            .processes
3767            .destroy(attempt)
3768            .map_err(LifecycleError::Failed)?
3769        {
3770            ProviderDestroy::Destroyed => {}
3771            ProviderDestroy::Deferred(reason) => {
3772                return Err(LifecycleError::Failed(FailureReason::Other(reason.into())));
3773            }
3774        }
3775        let outcome = attempt
3776            .outcome()
3777            .cloned()
3778            .ok_or(LifecycleError::Transition)?;
3779        self.preserve_diagnostics(attempt, &outcome)?;
3780        self.scrub_workspace(attempt)?;
3781        self.ports
3782            .packages
3783            .release(attempt.id)
3784            .map_err(LifecycleError::Failed)?;
3785        attempt
3786            .clean(self.ports.clock.now())
3787            .map_err(|_| LifecycleError::Transition)?;
3788        self.record(attempt)?;
3789        let kind = OutcomeKind::of(&outcome);
3790        self.ports.events.emit(AttemptEvent::Cleaned {
3791            attempt: attempt.id,
3792            outcome: kind,
3793        });
3794        self.ports
3795            .reconcile_events
3796            .emit(LifecycleEvent::AttemptCleaned {
3797                policy: attempt.policy_id,
3798                attempt: attempt.id,
3799                outcome: kind,
3800            });
3801        Ok(())
3802    }
3803
3804    fn clean_isolated_attempt(&self, attempt: &mut RunnerAttempt) -> Result<(), LifecycleError> {
3805        if attempt.state() != AttemptState::Destroying {
3806            attempt
3807                .begin_destroy(self.ports.clock.now())
3808                .map_err(|_| LifecycleError::Transition)?;
3809            self.record(attempt)?;
3810        }
3811        let provider = self.ports.processes.as_ref();
3812        let allocation_resources = provider.enumerate_owned(self.host_id);
3813        let matching_allocation = allocation_resources
3814            .iter()
3815            .filter(|identity| identity_same_allocation(self.host_id, attempt, identity))
3816            .collect::<Vec<_>>();
3817        if matching_allocation.len() > 1
3818            || matching_allocation
3819                .iter()
3820                .any(|identity| !identity_matches_intent(self.host_id, attempt, identity))
3821        {
3822            self.ports
3823                .events
3824                .emit(AttemptEvent::QuarantinedOwnedResource {
3825                    attempt: attempt.id,
3826                });
3827            tracing::warn!(attempt_id = %attempt.id, reason = "duplicate_or_mismatched_isolated_ownership", "provider resources quarantined for operator review");
3828            return Err(self.defer_environment_cleanup(attempt)?);
3829        }
3830        let has_identity = matches!(
3831            attempt.execution(),
3832            AttemptExecution::Isolated {
3833                environment_id: Some(_),
3834                ..
3835            }
3836        );
3837        if !has_identity
3838            && allocation_resources
3839                .iter()
3840                .any(|identity| identity_matches_intent(self.host_id, attempt, identity))
3841        {
3842            return Err(self.defer_environment_cleanup(attempt)?);
3843        }
3844        let current = if has_identity {
3845            provider.inspect(attempt)
3846        } else {
3847            // A prepare effect may have succeeded before its identity could be
3848            // journalled. Empty enumeration alone is not proof of absence.
3849            provider.recover(attempt)
3850        };
3851        let current = match current {
3852            Ok(state) => state,
3853            Err(_) => return Err(self.defer_environment_cleanup(attempt)?),
3854        };
3855        if !has_identity && current != EnvironmentState::Missing {
3856            return Err(self.defer_environment_cleanup(attempt)?);
3857        }
3858        if current != EnvironmentState::Missing {
3859            if !matches!(provider.owns(attempt), Ok(true)) {
3860                self.ports
3861                    .events
3862                    .emit(AttemptEvent::QuarantinedOwnedResource {
3863                        attempt: attempt.id,
3864                    });
3865                tracing::warn!(attempt_id = %attempt.id, reason = "isolated_resource_ownership_mismatch", "provider resource quarantined for operator review");
3866                return Err(self.defer_environment_cleanup(attempt)?);
3867            }
3868            if matches!(
3869                current,
3870                EnvironmentState::Starting | EnvironmentState::Running
3871            ) && !matches!(provider.stop(attempt), Ok(ProviderStop::Stopped))
3872            {
3873                return Err(self.defer_environment_cleanup(attempt)?);
3874            }
3875            if !matches!(provider.destroy(attempt), Ok(ProviderDestroy::Destroyed)) {
3876                return Err(self.defer_environment_cleanup(attempt)?);
3877            }
3878            if !matches!(provider.inspect(attempt), Ok(EnvironmentState::Missing)) {
3879                return Err(self.defer_environment_cleanup(attempt)?);
3880            }
3881        }
3882        if provider
3883            .enumerate_owned(self.host_id)
3884            .iter()
3885            .any(|identity| identity_same_allocation(self.host_id, attempt, identity))
3886        {
3887            return Err(self.defer_environment_cleanup(attempt)?);
3888        }
3889        let outcome = attempt
3890            .outcome()
3891            .cloned()
3892            .ok_or(LifecycleError::Transition)?;
3893        // Provider diagnostics are a closed vocabulary and bounded before they
3894        // can enter tracing, even if an adapter returns an unbounded vector.
3895        for diagnostic in provider.diagnostics(attempt).into_iter().take(32) {
3896            tracing::debug!(attempt_id = %attempt.id, provider_diagnostic = ?diagnostic, "provider lifecycle diagnostic");
3897        }
3898        self.preserve_diagnostics(attempt, &outcome)?;
3899        self.scrub_workspace(attempt)?;
3900        self.ports
3901            .packages
3902            .release(attempt.id)
3903            .map_err(LifecycleError::Failed)?;
3904        attempt
3905            .clean_isolated(self.ports.clock.now())
3906            .map_err(|_| LifecycleError::Transition)?;
3907        self.record(attempt)?;
3908        self.ports.events.emit(AttemptEvent::Cleaned {
3909            attempt: attempt.id,
3910            outcome: OutcomeKind::of(&outcome),
3911        });
3912        self.ports
3913            .reconcile_events
3914            .emit(LifecycleEvent::AttemptCleaned {
3915                policy: attempt.policy_id,
3916                attempt: attempt.id,
3917                outcome: OutcomeKind::of(&outcome),
3918            });
3919        Ok(())
3920    }
3921
3922    fn defer_environment_cleanup(
3923        &self,
3924        attempt: &mut RunnerAttempt,
3925    ) -> Result<LifecycleError, LifecycleError> {
3926        attempt
3927            .defer_cleanup(self.ports.clock.now())
3928            .map_err(|_| LifecycleError::Transition)?;
3929        self.record(attempt)?;
3930        Ok(LifecycleError::EnvironmentCleanupDeferred)
3931    }
3932
3933    /// Undo an attempt's placement by the algorithm its journalled workspace
3934    /// kind makes legal (`02-target-architecture.md`, "Cleanup and recovery").
3935    ///
3936    /// The dispatch is on the *journal*, never on what the directory looks like
3937    /// now. A slot whose `_work` was replaced by a junction is still scrubbed as
3938    /// a slot rather than removed whole, and a disposable directory that happens
3939    /// to contain a `_work` still goes whole rather than being spared: the
3940    /// workspace kind is immutable precisely so that the shape of a directory a
3941    /// workflow can write to cannot choose the algorithm applied to it.
3942    fn scrub_workspace(&self, attempt: &RunnerAttempt) -> Result<(), LifecycleError> {
3943        #[cfg(test)]
3944        {
3945            // The two contamination mutants `f1` drives the security gates with.
3946            // They sit in front of the dispatch rather than inside one arm so
3947            // that a skipped cleanup is equally observable in both modes.
3948            if matches!(
3949                std::env::var("RUNNER_MANAGER_TEST_MUTANT").as_deref(),
3950                Ok("skip_workspace_cleanup" | "reuse_job_workspace")
3951            ) {
3952                return Ok(());
3953            }
3954        }
3955        match attempt.workspace() {
3956            AttemptWorkspace::Ephemeral => match remove_runtime_tree(attempt.runtime_path()) {
3957                Ok(()) => Ok(()),
3958                Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
3959                Err(_) => Err(LifecycleError::WorkspaceCleanupDeferred),
3960            },
3961            AttemptWorkspace::PersistentSlot { slot } => self.scrub_persistent_slot(attempt, slot),
3962        }
3963    }
3964
3965    /// Retain exactly `_work` and prove everything else is gone.
3966    ///
3967    /// The seven ordered checks of `04-security-recovery.md`, "Safe path
3968    /// handling": the journalled root and slot, a surviving policy's agreement,
3969    /// lexical and canonical containment, a literal enumeration, the one-entry
3970    /// allowlist, and a verification pass before the caller releases the package
3971    /// lease and marks the attempt cleaned. Any of them may refuse, and a
3972    /// refusal removes nothing after it.
3973    ///
3974    /// A slot directory that is already gone is not a refusal. There is nothing
3975    /// to scrub and nothing to prove absent, which is the same tolerance the
3976    /// disposable arm has always had for a runtime that vanished under it.
3977    fn scrub_persistent_slot(
3978        &self,
3979        attempt: &RunnerAttempt,
3980        slot: NonZeroU16,
3981    ) -> Result<(), LifecycleError> {
3982        // The policy is read from the journal, never inferred from the
3983        // directory tree, and its absence is legal: `Reconciler` cleans every
3984        // concluded attempt whether or not its policy still exists, and this
3985        // one's root is already journalled on the attempt itself.
3986        let configured = self
3987            .ports
3988            .store
3989            .policy(attempt.policy_id)
3990            .map_err(|_| LifecycleError::Journal)?
3991            .and_then(|policy| match policy.workspace_policy() {
3992                WorkspacePolicy::Persistent { root } => Some(root.clone()),
3993                WorkspacePolicy::Ephemeral => None,
3994            });
3995        let runtime = attempt.runtime_path();
3996        self.quarantine_on_refusal(
3997            attempt,
3998            verify_journalled_slot(runtime, slot, configured.as_ref())
3999                .and_then(|()| slot_is_present(runtime))
4000                .and_then(|present| {
4001                    if present {
4002                        scrub_slot_entries(runtime).and_then(|()| verify_slot_scrubbed(runtime))
4003                    } else {
4004                        Ok(())
4005                    }
4006                }),
4007        )
4008    }
4009
4010    /// Turn a slot refusal into the error that keeps the attempt uncleaned.
4011    ///
4012    /// The warning is emitted here, at the one place a quarantine is minted, so
4013    /// that both routes out of cleanup carry it: the reconciler's terminal sweep,
4014    /// which receives the error through the launcher port, and
4015    /// [`Self::clean_or_quarantine`], which swallows it to keep the pass alive.
4016    /// Everything logged is either a path this product configured or a constant
4017    /// from this module — see [`SlotQuarantine`] for why that matters.
4018    fn quarantine_on_refusal(
4019        &self,
4020        attempt: &RunnerAttempt,
4021        outcome: Result<(), SlotQuarantine>,
4022    ) -> Result<(), LifecycleError> {
4023        let Err(quarantine) = outcome else {
4024            return Ok(());
4025        };
4026        let detail = quarantine.to_string();
4027        tracing::warn!(
4028            attempt = %attempt.id,
4029            policy = %attempt.policy_id,
4030            slot = attempt.workspace().slot_number(),
4031            refusal = quarantine.refusal.class(),
4032            "{detail}"
4033        );
4034        Err(LifecycleError::SlotQuarantined {
4035            class: quarantine.refusal.class(),
4036            detail,
4037        })
4038    }
4039
4040    fn preserve_diagnostics(
4041        &self,
4042        attempt: &RunnerAttempt,
4043        outcome: &AttemptOutcome,
4044    ) -> Result<(), LifecycleError> {
4045        fs::create_dir_all(&self.diagnostics_root).map_err(|_| {
4046            LifecycleError::Failed(FailureReason::Other(
4047                "diagnostics directory could not be created".into(),
4048            ))
4049        })?;
4050        // Intentionally constructed from typed local facts, not runner output.
4051        // Raw child output can contain workflow secrets and is never copied.
4052        let reason_kind = match outcome {
4053            AttemptOutcome::Failed { reason } => failure_reason_kind(reason),
4054            _ => "none",
4055        };
4056        let diagnostic = format!(
4057            "attempt_id={}\npolicy_id={}\noutcome={}\nreason={}\n",
4058            attempt.id,
4059            attempt.policy_id,
4060            OutcomeKind::of(outcome).as_str(),
4061            reason_kind,
4062        );
4063        fs::write(
4064            self.diagnostics_root.join(format!("{}.log", attempt.id)),
4065            diagnostic,
4066        )
4067        .map_err(|_| {
4068            LifecycleError::Failed(FailureReason::Other(
4069                "redacted diagnostics could not be preserved".into(),
4070            ))
4071        })
4072    }
4073
4074    async fn materialize_with_retry(
4075        &self,
4076        policy: &ScalePolicy,
4077        attempt: &RunnerAttempt,
4078    ) -> Result<RunnerVersion, FailureReason> {
4079        let mut issued = 0_u32;
4080        loop {
4081            issued = issued.saturating_add(1);
4082            match self.ports.packages.materialize(attempt).await {
4083                Ok(version) => return Ok(version),
4084                Err(reason)
4085                    if package_failure_is_terminal(&reason)
4086                        || issued >= self.retry.max_attempts.max(1) =>
4087                {
4088                    return Err(reason);
4089                }
4090                Err(reason) => {
4091                    if !self.ports.demand.persists(policy.id).await {
4092                        return Err(reason);
4093                    }
4094                    let delay = self.retry.delay(issued);
4095                    self.ports.events.emit(AttemptEvent::Retry {
4096                        attempt: attempt.id,
4097                        operation: "package_materialization",
4098                        delay,
4099                    });
4100                    self.ports.delay.wait(delay).await;
4101                    if !self.ports.demand.persists(policy.id).await {
4102                        return Err(reason);
4103                    }
4104                }
4105            }
4106        }
4107    }
4108
4109    async fn register_with_retry(
4110        &self,
4111        policy: &ScalePolicy,
4112        attempt: AttemptId,
4113        request: &JitRunnerRequest,
4114    ) -> Result<JitRegistration, LifecycleError> {
4115        let mut issued = 0_u32;
4116        loop {
4117            issued = issued.saturating_add(1);
4118            match self
4119                .ports
4120                .github
4121                .register(&policy.target, request, &self.cancel)
4122                .await
4123            {
4124                Ok(registration) => return Ok(registration),
4125                Err(error) if error.terminal => {
4126                    return Err(LifecycleError::Failed(error.reason));
4127                }
4128                Err(error) => {
4129                    if issued >= self.retry.max_attempts.max(1)
4130                        || !self.ports.demand.persists(policy.id).await
4131                    {
4132                        return Err(LifecycleError::Failed(error.reason));
4133                    }
4134                    let delay = error
4135                        .retry_after
4136                        .unwrap_or_else(|| self.retry.delay(issued));
4137                    self.ports.events.emit(AttemptEvent::Retry {
4138                        attempt,
4139                        operation: "jit_request",
4140                        delay,
4141                    });
4142                    self.ports.delay.wait(delay).await;
4143                    if !self.ports.demand.persists(policy.id).await {
4144                        return Err(LifecycleError::Failed(error.reason));
4145                    }
4146                }
4147            }
4148        }
4149    }
4150
4151    /// Where one attempt's files go, decided while the host allocation lock is
4152    /// held and before anything external happens.
4153    ///
4154    /// The branch is on the *repository's configured* workspace policy, so an
4155    /// organization policy and an ephemeral repository never reach slot
4156    /// selection at all: a persistent policy is unrepresentable for an
4157    /// organization target (D7, refused by `WorkspacePolicy::permitted_for` in
4158    /// both the constructor and the loader), and an ephemeral repository takes
4159    /// the disposable arm that existed before slots did.
4160    fn allocate_workspace(
4161        &self,
4162        policy: &ScalePolicy,
4163        id: AttemptId,
4164    ) -> Result<Placement, LifecycleError> {
4165        let placement = match policy.workspace_policy() {
4166            // Precedence (`02-target-architecture.md`): the repository's
4167            // persistent root is selected *before* the host root, which is why
4168            // this arm is first and why it never makes resolving the host
4169            // default a precondition of its own success.
4170            WorkspacePolicy::Persistent { root } => self.allocate_persistent_slot(policy, root),
4171            WorkspacePolicy::Ephemeral => self.allocate_disposable(policy, id),
4172        };
4173        // Here rather than inside the two arms, so that every path a root can
4174        // accept clears the record and none can be forgotten.
4175        if placement.is_ok() {
4176            self.root_accepted(policy.id);
4177        }
4178        placement
4179    }
4180
4181    /// `Host.runner_root_override`, read from the journal.
4182    ///
4183    /// Separated from [`Self::effective_host_root`] so that the two failures it
4184    /// folds together stay apart: an unreadable or missing host row is a journal
4185    /// problem and is always fatal, while an unresolvable *platform default* is
4186    /// only fatal to a placement that actually needs the host root.
4187    fn configured_host_root(&self) -> Result<Option<LocalAbsolutePath>, LifecycleError> {
4188        let host = self
4189            .ports
4190            .store
4191            .host(self.host_id)
4192            .map_err(|_| LifecycleError::Journal)?
4193            .ok_or_else(|| LifecycleError::Failed(FailureReason::Other("host not found".into())))?;
4194        Ok(host.runner_root_override.clone())
4195    }
4196
4197    /// `Host.runner_root_override`, or the platform default when that resolves;
4198    /// `None` when neither does. Unlike [`Self::effective_host_root`] it records
4199    /// no refusal, for callers to whom an unresolvable root is not a failure.
4200    fn resolvable_host_root(&self) -> Result<Option<LocalAbsolutePath>, LifecycleError> {
4201        Ok(self
4202            .configured_host_root()?
4203            .or_else(|| default_runner_root(&self.app_paths).ok()))
4204    }
4205
4206    /// `Host.runner_root_override`, or the platform default standing in for it.
4207    ///
4208    /// Takes the policy because an unresolvable default is a refusal like any
4209    /// other, and the record it leaves is that policy's.
4210    fn effective_host_root(
4211        &self,
4212        policy: &ScalePolicy,
4213    ) -> Result<LocalAbsolutePath, LifecycleError> {
4214        match self.configured_host_root()? {
4215            Some(configured) => Ok(configured),
4216            None => default_runner_root(&self.app_paths).map_err(|error| {
4217                // No root resolved, so there is no path to name but the one the
4218                // platform would have produced.
4219                self.root_refused(policy.id, "the platform default runner root", error)
4220            }),
4221        }
4222    }
4223
4224    /// Turns a runner-root refusal into a failure, and leaves the sentence
4225    /// somewhere an operator can read it.
4226    ///
4227    /// Per policy, because the policies on a host do not share a fate: one with
4228    /// its own persistent root places runners while another on a withheld
4229    /// volume places none, and a host-wide record would have the first clear
4230    /// the second's on the same pass.
4231    ///
4232    /// The recording is best-effort and its failure is deliberately swallowed:
4233    /// this runs on the path that is already failing, and a host that cannot
4234    /// write a diagnostic file must still report the refusal it came to report.
4235    /// `service status` says so itself when the file is unreadable.
4236    fn root_refused(&self, policy: PolicyId, root: &str, error: RunnerRootError) -> LifecycleError {
4237        let _ = runner_manager_platform::service::record_runner_root_refusal(
4238            &self.app_paths,
4239            &policy.to_string(),
4240            self.ports.clock.now(),
4241            error.kind(),
4242            root,
4243            &error.to_string(),
4244        );
4245        root_failure(error)
4246    }
4247
4248    /// Clears that policy's record, because its root accepted a placement.
4249    ///
4250    /// Called on every successful placement rather than only after a failure:
4251    /// the daemon that recovers is often not the process that failed -- a
4252    /// self-update restarts it -- so "clear it if we wrote it" would leave a
4253    /// stale note on `service status` for as long as the host ran.
4254    fn root_accepted(&self, policy: PolicyId) {
4255        let _ = runner_manager_platform::service::clear_runner_root_refusal(
4256            &self.app_paths,
4257            &policy.to_string(),
4258        );
4259    }
4260
4261    /// D3's disposable placement: a unique child of the effective host root,
4262    /// removed whole on cleanup. `c1`'s behaviour, moved behind the branch.
4263    fn allocate_disposable(
4264        &self,
4265        policy: &ScalePolicy,
4266        id: AttemptId,
4267    ) -> Result<Placement, LifecycleError> {
4268        let effective_root = self.effective_host_root(policy)?;
4269        RootPreflight::new(&self.app_paths)
4270            .check(&RootOwner::Host, &effective_root)
4271            .map_err(|error| self.root_refused(policy.id, effective_root.as_str(), error))?;
4272        let runtime = effective_root.as_path().join({
4273            #[cfg(test)]
4274            {
4275                if std::env::var("RUNNER_MANAGER_TEST_MUTANT").as_deref()
4276                    == Ok("reuse_job_workspace")
4277                {
4278                    "mutant-shared-workspace".to_owned()
4279                } else {
4280                    workspace_name(id)
4281                }
4282            }
4283            #[cfg(not(test))]
4284            {
4285                workspace_name(id)
4286            }
4287        });
4288        fs::create_dir_all(&runtime)
4289            .map_err(|_| LifecycleError::Failed(FailureReason::ProcessStartFailed))?;
4290        Ok(Placement {
4291            runtime,
4292            workspace: AttemptWorkspace::Ephemeral,
4293        })
4294    }
4295
4296    /// D4/D5's persistent placement: the lowest free `sN` under the repository's
4297    /// configured root.
4298    ///
4299    /// Steps 1 to 6 of `02-target-architecture.md`, "Slot allocation", in order;
4300    /// step 7 is the journal write [`Self::record_allocation`] owns. All of them
4301    /// run under the host allocation lock, because [`Self::launch_attempt`] is
4302    /// reachable only through a `LaunchRequest` and that carries the guard.
4303    ///
4304    /// **The filesystem is never consulted to decide which slots are taken**
4305    /// (invariant 6). The leases come from the journal; the directory is
4306    /// inspected only to decide whether *this* slot is safe to reuse.
4307    fn allocate_persistent_slot(
4308        &self,
4309        policy: &ScalePolicy,
4310        root: &LocalAbsolutePath,
4311    ) -> Result<Placement, LifecycleError> {
4312        // 1-4. The lowest positive slot no uncleaned attempt holds, refused
4313        // above the policy ceiling.
4314        let ceiling = policy.max_capacity().ok_or_else(|| {
4315            LifecycleError::Failed(FailureReason::Other(
4316                "a persistent workspace needs the policy's max_capacity to bound its slots"
4317                    .to_string(),
4318            ))
4319        })?;
4320        let leases = self
4321            .ports
4322            .store
4323            .slot_leases_for_policy(policy.id)
4324            .map_err(|_| LifecycleError::Journal)?;
4325        let slot = lowest_free_slot(&leases, ceiling).ok_or_else(|| {
4326            LifecycleError::Failed(FailureReason::Other(format!(
4327                "every persistent slot s1 to s{ceiling} for {} is leased by an attempt that has \
4328                 not been cleaned, so no slot is free; raise the repository's max capacity, or \
4329                 finish cleaning a concluded attempt",
4330                policy.target
4331            )))
4332        })?;
4333        let workspace = AttemptWorkspace::persistent_slot(slot);
4334        let name = workspace
4335            .slot_directory_name()
4336            .expect("a persistent allocation names its slot directory");
4337
4338        // The operational preflight, for the reasons the host root gets one: a
4339        // root that is remote, unwritable, or overlapping application data has
4340        // to fail before a directory is created rather than after. The host
4341        // root is registered only as something *not* to overlap; a host default
4342        // that cannot be resolved is a host-root problem and does not block a
4343        // repository that configured a root of its own.
4344        //
4345        // Only *that* failure is tolerated. An unreadable host row is a journal
4346        // failure and propagates, because silently continuing would drop the
4347        // overlap check entirely and accept a repository root that sits inside
4348        // the host root — the pair `RootPreflight` exists to refuse.
4349        let host_root = self.resolvable_host_root()?;
4350        let mut preflight = RootPreflight::new(&self.app_paths);
4351        if let Some(host_root) = host_root {
4352            preflight = preflight.against(RootOwner::Host, host_root);
4353        }
4354        let checked = preflight
4355            .check(&RootOwner::Repository(policy.target.to_string()), root)
4356            .map_err(|error| self.root_refused(policy.id, root.as_str(), error))?;
4357        if let Some(leaf) = checked.leaf_to_create() {
4358            fs::create_dir(leaf).map_err(|source| {
4359                LifecycleError::Failed(FailureReason::Other(format!(
4360                    "the persistent workspace root {} could not be created: {source}",
4361                    leaf.display()
4362                )))
4363            })?;
4364        }
4365
4366        // 5-6. `<root>/sN`, contained lexically by construction, then created or
4367        // validated, then contained canonically now that it resolves, and only
4368        // then accepted for reuse.
4369        let slot_path = runner_root::derive_child(root, &name)
4370            .map_err(|error| self.root_refused(policy.id, root.as_str(), error))?;
4371        create_or_validate_slot(slot_path.as_path())?;
4372        runner_root::verify_containment(root, &slot_path)
4373            .map_err(|error| self.root_refused(policy.id, root.as_str(), error))?;
4374        accept_reusable_slot(slot_path.as_path())?;
4375        Ok(Placement {
4376            runtime: slot_path.as_path().to_path_buf(),
4377            workspace,
4378        })
4379    }
4380
4381    /// The first journal write of an attempt, where a duplicate slot lease is
4382    /// still possible and has to be reported as itself.
4383    ///
4384    /// [`Self::record`] flattens every store failure into
4385    /// [`LifecycleError::Journal`], which is right for a state transition and
4386    /// wrong here: the partial unique index
4387    /// `one_uncleaned_persistent_attempt_per_slot` is the final race fence
4388    /// (`04-security-recovery.md`, "two attempts use one slot concurrently"),
4389    /// and an operator who reaches it needs to read that rather than "attempt
4390    /// journal operation failed". Nothing was written, so the caller returns
4391    /// without concluding an attempt that is not in the journal.
4392    fn record_allocation(&self, attempt: &RunnerAttempt) -> Result<(), LifecycleError> {
4393        match self.ports.store.record_attempt(attempt) {
4394            Ok(()) => {
4395                self.ports.events.emit(AttemptEvent::State {
4396                    attempt: attempt.id,
4397                    state: attempt.state(),
4398                });
4399                Ok(())
4400            }
4401            Err(error @ StoreError::SlotAlreadyLeased { .. }) => Err(LifecycleError::Failed(
4402                FailureReason::Other(error.to_string()),
4403            )),
4404            Err(_) => Err(LifecycleError::Journal),
4405        }
4406    }
4407
4408    async fn launch_attempt(
4409        &self,
4410        policy: &ScalePolicy,
4411        allocation_guard: &AllocationGuard,
4412        lock_wait: Duration,
4413    ) -> Result<RunnerAttempt, LifecycleError> {
4414        let (attempt, labels) = self.allocate_attempt(policy)?;
4415        let id = attempt.id;
4416        let mut stopwatch = LaunchStopwatch::new(lock_wait);
4417        let result = self
4418            .launch_steps(policy, allocation_guard, attempt, labels, &mut stopwatch)
4419            .await;
4420        stopwatch.finish();
4421        self.ports.events.emit(AttemptEvent::Launched {
4422            attempt: id,
4423            started: result.is_ok(),
4424            timings: stopwatch.timings,
4425        });
4426        result
4427    }
4428
4429    /// Place and journal a new attempt: everything before its first external
4430    /// effect, and so before there is anything to time.
4431    fn allocate_attempt<'p>(
4432        &self,
4433        policy: &'p ScalePolicy,
4434    ) -> Result<(RunnerAttempt, &'p RoutingLabels), LifecycleError> {
4435        if !*self
4436            .recovery_complete
4437            .lock()
4438            .map_err(|_| LifecycleError::Journal)?
4439        {
4440            return Err(LifecycleError::RecoveryIncomplete);
4441        }
4442        let capability = self.ports.processes.probe(policy);
4443        if capability != ProviderCapability::Ready {
4444            return Err(LifecycleError::Failed(capability.refusal()));
4445        }
4446        let resolved = self.ports.processes.resolve(policy).map_err(|reason| {
4447            LifecycleError::Failed(if policy.execution_policy().is_native() {
4448                reason
4449            } else {
4450                safe_isolation_reason(reason, IsolationProviderFailure::RuntimeOperationFailed)
4451            })
4452        })?;
4453        if policy.execution_policy().is_native() != resolved.is_none() {
4454            return Err(LifecycleError::Failed(FailureReason::Other(
4455                "provider resolution mismatch".into(),
4456            )));
4457        }
4458        if let Some(resolution) = &resolved {
4459            if resolution.provider_kind == Backend::Auto {
4460                return Err(LifecycleError::Failed(FailureReason::Other(
4461                    "provider resolution mismatch".into(),
4462                )));
4463            }
4464            AttemptExecution::Isolated {
4465                provider_kind: resolution.provider_kind,
4466                environment_id: None,
4467                resolved_image: resolution.image.clone(),
4468                generation: "generation-check".into(),
4469            }
4470            .validate()
4471            .map_err(|_| {
4472                LifecycleError::Failed(FailureReason::Other(
4473                    "resolved image is incompatible".into(),
4474                ))
4475            })?;
4476        }
4477        let labels = policy
4478            .routing_labels()
4479            .ok_or(LifecycleError::Failed(FailureReason::JitRequestFailed))?;
4480        let id = AttemptId::new_random();
4481        let placement = self.allocate_workspace(policy, id)?;
4482        let mut attempt = RunnerAttempt::allocate_in(
4483            id,
4484            policy.id,
4485            placement.runtime,
4486            placement.workspace,
4487            self.ports.clock.now(),
4488        );
4489        if let Some(resolution) = &resolved {
4490            attempt
4491                .allocate_execution(AttemptExecution::Isolated {
4492                    provider_kind: resolution.provider_kind,
4493                    environment_id: None,
4494                    resolved_image: resolution.image.clone(),
4495                    generation: uuid::Uuid::new_v4().to_string(),
4496                })
4497                .map_err(|_| LifecycleError::Transition)?;
4498        }
4499        // This is deliberately the first effect after directory allocation, and
4500        // for a persistent attempt it is also what makes the slot lease durable
4501        // before any package or GitHub effect
4502        // (`02-target-architecture.md`, "Slot allocation", step 7).
4503        self.record_allocation(&attempt)?;
4504        Ok((attempt, labels))
4505    }
4506
4507    async fn launch_steps(
4508        &self,
4509        policy: &ScalePolicy,
4510        allocation_guard: &AllocationGuard,
4511        mut attempt: RunnerAttempt,
4512        labels: &RoutingLabels,
4513        stopwatch: &mut LaunchStopwatch,
4514    ) -> Result<RunnerAttempt, LifecycleError> {
4515        let id = attempt.id;
4516        let isolated = !attempt.execution().is_native();
4517        stopwatch.begin(LaunchStep::Materialize);
4518        let version = match self.materialize_with_retry(policy, &attempt).await {
4519            Ok(version) => version,
4520            Err(reason) => return self.fail_launch(&mut attempt, reason),
4521        };
4522        self.prune_under_allocation_lock(allocation_guard, &version)?;
4523        self.versions
4524            .lock()
4525            .map_err(|_| LifecycleError::Journal)?
4526            .insert(id, version);
4527
4528        if isolated {
4529            attempt
4530                .begin_prepare(self.ports.clock.now())
4531                .map_err(|_| LifecycleError::Transition)?;
4532            self.record(&attempt)?;
4533        }
4534        let prepared = match self.ports.processes.prepare(&attempt, policy) {
4535            Ok(prepared) => prepared,
4536            Err(reason) => {
4537                let safe_reason = if isolated {
4538                    safe_isolation_reason(reason, IsolationProviderFailure::RuntimeOperationFailed)
4539                } else {
4540                    reason
4541                };
4542                return self.fail_launch(&mut attempt, safe_reason);
4543            }
4544        };
4545        if isolated {
4546            let Some(identity) = prepared.identity() else {
4547                return Err(LifecycleError::Failed(FailureReason::IsolationProvider(
4548                    IsolationProviderFailure::OwnershipMismatch,
4549                )));
4550            };
4551            if !identity_matches_intent(self.host_id, &attempt, identity) {
4552                return Err(LifecycleError::Failed(FailureReason::IsolationProvider(
4553                    IsolationProviderFailure::OwnershipMismatch,
4554                )));
4555            }
4556            let EnvironmentIdentity::Isolated { environment_id, .. } = identity else {
4557                unreachable!()
4558            };
4559            attempt
4560                .prepared_environment(environment_id.clone())
4561                .map_err(|_| LifecycleError::Transition)?;
4562            attempt
4563                .mark_prepared(self.ports.clock.now())
4564                .map_err(|_| LifecycleError::Transition)?;
4565            self.record(&attempt)?;
4566        }
4567
4568        stopwatch.begin(LaunchStep::Register);
4569        let jit_request =
4570            JitRunnerRequest::for_policy(runner_name(id), self.runner_group_id, labels);
4571        let registration = match self.register_with_retry(policy, id, &jit_request).await {
4572            Ok(registration) => registration,
4573            Err(error) => return self.fail_launch(&mut attempt, error.reason()),
4574        };
4575        stopwatch.begin(LaunchStep::Spawn);
4576        let runner_id = registration.runner().id;
4577        write_runner_id(attempt.runtime_path(), runner_id)?;
4578        attempt
4579            .jit_received(self.ports.clock.now())
4580            .map_err(|_| LifecycleError::Transition)?;
4581        self.record(&attempt)?;
4582        let config = registration.into_config();
4583        if isolated {
4584            let started = self
4585                .ports
4586                .processes
4587                .start(prepared, &attempt, OneTimeJitHandoff::new(&config))
4588                .map_err(|failure| {
4589                    LifecycleError::Failed(safe_isolation_reason(
4590                        failure.reason,
4591                        IsolationProviderFailure::RuntimeOperationFailed,
4592                    ))
4593                })?;
4594            if !identity_matches_intent(self.host_id, &attempt, &started) {
4595                return Err(LifecycleError::Failed(FailureReason::IsolationProvider(
4596                    IsolationProviderFailure::OwnershipMismatch,
4597                )));
4598            }
4599            attempt
4600                .started_isolated(self.ports.clock.now())
4601                .map_err(|_| LifecycleError::Transition)?;
4602            self.record(&attempt)?;
4603            return Ok(attempt);
4604        }
4605        let mut issued = 0_u32;
4606        let pid = loop {
4607            issued = issued.saturating_add(1);
4608            match self.ports.processes.start(
4609                prepared.clone(),
4610                &attempt,
4611                OneTimeJitHandoff::new(&config),
4612            ) {
4613                Ok(EnvironmentIdentity::NativeProcess(pid)) => break pid,
4614                Ok(EnvironmentIdentity::Isolated { .. }) => {
4615                    // No isolated launch sequence is wired in this task. Keep
4616                    // the nonterminal JIT journal as a capacity fence rather
4617                    // than treating an unexpected resource as a native PID.
4618                    return Err(LifecycleError::Failed(FailureReason::Other(
4619                        "unexpected isolated environment identity".into(),
4620                    )));
4621                }
4622                Err(error) => {
4623                    if let Some(pid) = error.live_pid {
4624                        attempt
4625                            .started(pid, self.ports.clock.now())
4626                            .map_err(|_| LifecycleError::Transition)?;
4627                        self.record(&attempt)?;
4628                        return Err(LifecycleError::Failed(error.reason));
4629                    }
4630                    if !error.retryable
4631                        || issued >= self.retry.max_attempts.max(1)
4632                        || !self.ports.demand.persists(policy.id).await
4633                    {
4634                        return self.fail_launch(&mut attempt, error.reason);
4635                    }
4636                    let delay = self.retry.delay(issued);
4637                    self.ports.events.emit(AttemptEvent::Retry {
4638                        attempt: attempt.id,
4639                        operation: "process_start",
4640                        delay,
4641                    });
4642                    self.ports.delay.wait(delay).await;
4643                    if !self.ports.demand.persists(policy.id).await {
4644                        return self.fail_launch(&mut attempt, error.reason);
4645                    }
4646                }
4647            }
4648        };
4649        attempt
4650            .started(pid, self.ports.clock.now())
4651            .map_err(|_| LifecycleError::Transition)?;
4652        self.record(&attempt)?;
4653        Ok(attempt)
4654    }
4655
4656    fn fail_launch<T>(
4657        &self,
4658        attempt: &mut RunnerAttempt,
4659        reason: FailureReason,
4660    ) -> Result<T, LifecycleError> {
4661        self.conclude(attempt, AttemptOutcome::failed(reason.clone()))?;
4662        Err(LifecycleError::Failed(reason))
4663    }
4664
4665    /// e2's prune guard is invoked only with e1's allocation guard borrowed.
4666    /// The otherwise-unused argument is a compile-time witness of the ordering.
4667    fn prune_under_allocation_lock(
4668        &self,
4669        guard: &AllocationGuard,
4670        version: &RunnerVersion,
4671    ) -> Result<(), LifecycleError> {
4672        let attempts = self
4673            .ports
4674            .store
4675            .attempts()
4676            .map_err(|_| LifecycleError::Journal)?;
4677        self.ports
4678            .packages
4679            .prune_obsolete_guarded(
4680                PruneAuthority::from_launch_request(guard),
4681                version,
4682                &attempts,
4683            )
4684            .map_err(LifecycleError::Failed)?;
4685        #[cfg(target_os = "macos")]
4686        self.prune_abandoned_seeds(&attempts);
4687        Ok(())
4688    }
4689
4690    /// Remove the package seed from every runner root this host once placed a
4691    /// runtime in and no longer does: a changed host root override, a removed
4692    /// persistent policy, or one that moved its workspace.
4693    ///
4694    /// The roots ever used are the parents of the journal's runtime paths; the
4695    /// roots in use are the effective host root and every persistent policy's
4696    /// root. When the effective host root cannot be resolved nothing is
4697    /// removed, because a seed still in use cannot then be told apart. Runs
4698    /// under the allocation lock, so no launch is cloning from a seed it
4699    /// removes, and it is best-effort: a seed left behind costs disk, never a
4700    /// launch.
4701    #[cfg(target_os = "macos")]
4702    fn prune_abandoned_seeds(&self, attempts: &[RunnerAttempt]) {
4703        let Ok(policies) = self.ports.store.policies() else {
4704            return;
4705        };
4706        let Ok(Some(host_root)) = self.resolvable_host_root() else {
4707            return;
4708        };
4709        let in_use: BTreeSet<PathBuf> = policies
4710            .iter()
4711            .filter_map(|policy| match policy.workspace_policy() {
4712                WorkspacePolicy::Persistent { root } => Some(canonical_or_raw(root.as_path())),
4713                WorkspacePolicy::Ephemeral => None,
4714            })
4715            .chain(std::iter::once(canonical_or_raw(host_root.as_path())))
4716            .collect();
4717        // Distinct parents first: the journal keeps every attempt ever made.
4718        let used: BTreeSet<&Path> = attempts
4719            .iter()
4720            .filter_map(|attempt| attempt.runtime_path().parent())
4721            .collect();
4722        for root in used.into_iter().map(canonical_or_raw) {
4723            if !in_use.contains(&root) {
4724                let _ = remove_runtime_tree(&root.join(PACKAGE_SEED_DIR));
4725            }
4726        }
4727    }
4728}
4729
4730#[cfg(target_os = "macos")]
4731fn canonical_or_raw(path: &Path) -> PathBuf {
4732    fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
4733}
4734
4735#[async_trait]
4736impl RunnerLauncher for LifecycleLauncher {
4737    async fn host_capacity(
4738        &self,
4739        host: &runner_manager_domain::model::Host,
4740    ) -> Result<std::num::NonZeroU16, crate::reconcile::HostCapacityError> {
4741        self.ports
4742            .store
4743            .host(host.id)
4744            .map_err(|_| crate::reconcile::HostCapacityError::JournalUnreadable)?
4745            .map(|current| current.host_capacity)
4746            .ok_or(crate::reconcile::HostCapacityError::MissingHost)
4747    }
4748
4749    async fn supervise(
4750        &self,
4751        policy: &ScalePolicy,
4752    ) -> Result<Vec<ReplacementIntent>, LaunchFailure> {
4753        LifecycleLauncher::supervise(self, policy)
4754            .await
4755            .map_err(|error| LaunchFailure::new(error.reason()))
4756    }
4757
4758    async fn attempts(&self) -> Result<Vec<RunnerAttempt>, LaunchFailure> {
4759        self.ports.store.attempts().map_err(|_| {
4760            LaunchFailure::new(FailureReason::Other(
4761                "attempt journal could not be read".into(),
4762            ))
4763        })
4764    }
4765
4766    async fn launch(&self, request: LaunchRequest<'_>) -> Result<RunnerAttempt, LaunchFailure> {
4767        self.launch_attempt(request.policy, request.allocation_guard, request.lock_wait)
4768            .await
4769            .map_err(|error| LaunchFailure::new(error.reason()))
4770    }
4771
4772    async fn clean(&self, id: AttemptId) -> Result<(), LaunchFailure> {
4773        let mut attempt = self
4774            .ports
4775            .store
4776            .attempt(id)
4777            .map_err(|_| {
4778                LaunchFailure::new(FailureReason::Other(
4779                    "attempt journal could not be read".into(),
4780                ))
4781            })?
4782            .ok_or_else(|| {
4783                LaunchFailure::new(FailureReason::Other(
4784                    "attempt disappeared from the journal".into(),
4785                ))
4786            })?;
4787        self.clean_attempt(&mut attempt)
4788            .map_err(|error| LaunchFailure::new(error.reason()))?;
4789        self.cleanup_retries().succeeded(id);
4790        Ok(())
4791    }
4792}
4793
4794/// The name an attempt's runner registers with GitHub, which a
4795/// `workflow_job` webhook's `runner_name` repeats.
4796#[must_use]
4797pub fn runner_name(attempt: AttemptId) -> String {
4798    format!("runner-manager-{attempt}")
4799}
4800
4801/// How long reading a runner's diagnostics may take.
4802const EVIDENCE_DEADLINE: Duration = Duration::from_secs(2);
4803
4804/// What a native runner's own diagnostics say about whether it ran a job.
4805///
4806/// `Runner.Listener` writes `_diag/Runner_*.log` when it starts, and starts
4807/// `Runner.Worker` -- which writes `_diag/Worker_*.log` -- only for a job it has
4808/// acquired. So a worker log is proof a job ran, and a listener log without one
4809/// is proof none did. With neither, the directory is not where the listener
4810/// wrote, and nothing is concluded from it. `_diag` is scrubbed with the rest of
4811/// the runtime after every attempt, persistent slots included, so a log found
4812/// here belongs to this attempt.
4813#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4814enum JobEvidence {
4815    RanAJob,
4816    NoJob,
4817    Unknown,
4818}
4819
4820impl JobEvidence {
4821    fn of(runtime: &Path) -> Self {
4822        let diag = runtime.join("_diag");
4823        // Bounded: the daemon's loop runs on one thread, and a runtime on a
4824        // volume that does not answer (a hung disk, a pending macOS question)
4825        // must not stall it for longer than this.
4826        if runner_manager_platform::host_fitness::directory_responds(&diag, EVIDENCE_DEADLINE)
4827            != runner_manager_platform::host_fitness::Responsiveness::Responds
4828        {
4829            return Self::Unknown;
4830        }
4831        let Ok(entries) = fs::read_dir(diag) else {
4832            return Self::Unknown;
4833        };
4834        let mut listener = false;
4835        for entry in entries.flatten() {
4836            let name = entry.file_name();
4837            let name = name.to_string_lossy();
4838            if !name.ends_with(".log") {
4839                continue;
4840            }
4841            if name.starts_with("Worker_") {
4842                return Self::RanAJob;
4843            }
4844            listener |= name.starts_with("Runner_");
4845        }
4846        if listener { Self::NoJob } else { Self::Unknown }
4847    }
4848}
4849
4850/// The attempt's GitHub runner id: journalled, observed, or from the sidecar
4851/// written at registration.
4852fn known_runner_id(attempt: &RunnerAttempt, observed: Option<u64>) -> Option<u64> {
4853    attempt
4854        .github_runner_id()
4855        .or(observed)
4856        .or_else(|| read_runner_id(attempt.runtime_path()))
4857}
4858
4859fn read_runner_id(runtime: &Path) -> Option<u64> {
4860    fs::read_to_string(runtime.join(RUNNER_ID_FILE))
4861        .ok()?
4862        .trim()
4863        .parse()
4864        .ok()
4865}
4866
4867fn write_runner_id(runtime: &Path, runner_id: u64) -> Result<(), LifecycleError> {
4868    let target = runtime.join(RUNNER_ID_FILE);
4869    if let Some(existing) = read_runner_id(runtime) {
4870        return (existing == runner_id)
4871            .then_some(())
4872            .ok_or(LifecycleError::Journal);
4873    }
4874    let temporary = runtime.join(format!("{RUNNER_ID_FILE}.{}.tmp", uuid::Uuid::new_v4()));
4875    write_durable_file(&temporary, runner_id.to_string().as_bytes())
4876        .map_err(|_| LifecycleError::Journal)?;
4877    match fs::rename(&temporary, &target) {
4878        Ok(()) => sync_directory(runtime).map_err(|_| LifecycleError::Journal),
4879        Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
4880            let _ = fs::remove_file(&temporary);
4881            (read_runner_id(runtime) == Some(runner_id))
4882                .then_some(())
4883                .ok_or(LifecycleError::Journal)
4884        }
4885        Err(_) => {
4886            let _ = fs::remove_file(&temporary);
4887            Err(LifecycleError::Journal)
4888        }
4889    }
4890}
4891
4892fn write_durable_file(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
4893    let mut file = fs::OpenOptions::new()
4894        .create(true)
4895        .truncate(true)
4896        .write(true)
4897        .open(path)?;
4898    file.write_all(bytes)?;
4899    file.sync_all()?;
4900    let parent = path.parent().ok_or_else(|| {
4901        std::io::Error::new(
4902            std::io::ErrorKind::InvalidInput,
4903            "file has no parent directory",
4904        )
4905    })?;
4906    sync_directory(parent)
4907}
4908
4909#[cfg(unix)]
4910fn sync_directory(path: &Path) -> std::io::Result<()> {
4911    fs::File::open(path)?.sync_all()
4912}
4913
4914#[cfg(windows)]
4915fn sync_directory(path: &Path) -> std::io::Result<()> {
4916    use std::os::windows::fs::OpenOptionsExt;
4917
4918    const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000;
4919    const FILE_SHARE_ALL: u32 = 0x0000_0007;
4920    const GENERIC_WRITE: u32 = 0x4000_0000;
4921    fs::OpenOptions::new()
4922        .access_mode(GENERIC_WRITE)
4923        .share_mode(FILE_SHARE_ALL)
4924        .custom_flags(FILE_FLAG_BACKUP_SEMANTICS)
4925        .open(path)?
4926        .sync_all()
4927}
4928
4929#[cfg(test)]
4930mod tests {
4931    use super::*;
4932    use std::collections::BTreeSet;
4933    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
4934
4935    use crate::reconcile::{AllocationLock, InProcessAllocationLock};
4936    use runner_manager_domain::model::{Elapsed, TargetScope};
4937    use runner_manager_domain::store::SqliteStore;
4938    use runner_manager_github::jit::JitRunner;
4939    use runner_manager_testkit::clock::FakeClock;
4940    use runner_manager_testkit::fixtures;
4941
4942    #[test]
4943    fn production_provider_set_routes_every_implemented_isolated_backend() {
4944        let providers = PlatformExecutionProvider::new(HostId::from_u128(1));
4945        assert!(providers.isolated_provider(Backend::Auto).is_some());
4946        assert!(providers.isolated_provider(Backend::Oci).is_some());
4947        assert!(
4948            providers
4949                .isolated_provider(Backend::WindowsHyperVContainer)
4950                .is_some()
4951        );
4952        assert!(
4953            providers
4954                .isolated_provider(Backend::VirtualMachine)
4955                .is_none()
4956        );
4957    }
4958
4959    /// One event's fields, in the order they were recorded.
4960    type CapturedFields = Vec<(String, String)>;
4961
4962    /// Keeps the `(name, value)` fields of every `tracing` event emitted while
4963    /// it is installed.
4964    ///
4965    /// The names are kept and not just the rendered line, so a test can hold the
4966    /// event to `crate::logging`'s two rules -- the field allow-list and the
4967    /// value scrub -- instead of asserting that some string was passed to a
4968    /// macro.
4969    #[derive(Clone, Default)]
4970    struct CapturedEvents(std::sync::Arc<std::sync::Mutex<Vec<CapturedFields>>>);
4971
4972    impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for CapturedEvents {
4973        fn on_event(
4974            &self,
4975            event: &tracing::Event<'_>,
4976            _context: tracing_subscriber::layer::Context<'_, S>,
4977        ) {
4978            struct Collect(Vec<(String, String)>);
4979            impl tracing::field::Visit for Collect {
4980                fn record_debug(
4981                    &mut self,
4982                    field: &tracing::field::Field,
4983                    value: &dyn std::fmt::Debug,
4984                ) {
4985                    // `{value:?}` on a `&str` field would keep the quotes, and
4986                    // the redaction rules are about the value, not its literal.
4987                    self.0.push((
4988                        field.name().to_owned(),
4989                        format!("{value:?}").trim_matches('"').to_owned(),
4990                    ));
4991                }
4992            }
4993            let mut collected = Collect(Vec::new());
4994            event.record(&mut collected);
4995            self.0
4996                .lock()
4997                .expect("the capture mutex is not poisoned")
4998                .push(collected.0);
4999        }
5000    }
5001
5002    /// The regression behind a three-hour outage that showed nothing an operator
5003    /// could act on.
5004    ///
5005    /// A root the daemon cannot use refuses the launch *before*
5006    /// `record_allocation`, so no attempt row is ever written: `b2` has nothing
5007    /// to carry the failure on and `g2` has nothing to show it from. The
5008    /// lifecycle event keeps only the variant -- `failure_reason_kind` allows no
5009    /// free text on an event -- so the whole failure read
5010    /// `runner_start_failed reason=other`, once per poll, for hours.
5011    ///
5012    /// The assertion is deliberately made against the **production redaction
5013    /// rules** and not merely against what was emitted. An earlier attempt at
5014    /// this fix logged the rendered error on a `detail` field and passed a test
5015    /// exactly like this one, while shipping `detail="[redacted]"`: the field
5016    /// was not allow-listed, and had it been, the value is mostly paths and
5017    /// would have been scrubbed to `[path]`. A test that does not ask
5018    /// `crate::logging` what survives is a test that proves nothing.
5019    #[test]
5020    fn a_launch_the_runner_root_refused_names_the_cause_in_the_log_that_ships() {
5021        use runner_manager_platform::logging;
5022        use tracing_subscriber::layer::SubscriberExt as _;
5023
5024        let captured = CapturedEvents::default();
5025        let error = RunnerRootError::DeniedByPrivacyPolicy {
5026            requested: PathBuf::from("/Volumes/NVME/runners"),
5027            refused: PathBuf::from("/Volumes/NVME"),
5028            remediation: RootOwner::Host.remediation(),
5029        };
5030        let kind = error.kind();
5031
5032        let failure = tracing::subscriber::with_default(
5033            tracing_subscriber::registry().with(captured.clone()),
5034            || root_failure(error),
5035        );
5036
5037        // The reason still carries the whole sentence. Nothing on *this* path
5038        // renders it -- there is no attempt row, and the event carries only the
5039        // variant -- so this is a guard against a refactor that drops the detail
5040        // before some future surface can show it, and not a claim that one does.
5041        assert!(
5042            matches!(
5043                &failure,
5044                LifecycleError::Failed(FailureReason::Other(detail))
5045                    if detail.contains("/Volumes/NVME/runners")
5046                        && detail.contains("Full Disk Access")
5047            ),
5048            "the reason must still carry the detail: {failure:?}"
5049        );
5050
5051        let events = captured
5052            .0
5053            .lock()
5054            .expect("the capture mutex is not poisoned")
5055            .clone();
5056        let event = events
5057            .iter()
5058            .find(|fields| fields.iter().any(|(_, value)| value.contains(kind)))
5059            .unwrap_or_else(|| panic!("the refusal did not name its cause: {events:?}"));
5060
5061        // Held to the rules the real sink applies, by name and by value. An
5062        // earlier fix put the rendered error on an unlisted `detail` field and
5063        // shipped `[redacted]`; asserting only that something was emitted would
5064        // have passed then too.
5065        for (name, value) in event {
5066            assert!(
5067                logging::is_field_allowed(name),
5068                "`{name}` is not allow-listed, so it ships as `{}`: {event:?}",
5069                logging::REDACTION
5070            );
5071            assert_eq!(
5072                &logging::redact(value),
5073                value,
5074                "`{name}` does not survive value-shape scrubbing: {event:?}"
5075            );
5076        }
5077    }
5078
5079    /// A slot number, for the tests that name one.
5080    fn nz(slot: u16) -> NonZeroU16 {
5081        NonZeroU16::new(slot).expect("a positive slot")
5082    }
5083
5084    const JIT: &str = "eyJzZWNyZXQiOiJnaHBfRE9fTk9UX0xFQUsifQ==";
5085
5086    #[derive(Debug, Default)]
5087    struct FakeGithubLifecycle {
5088        registration_failures: Mutex<VecDeque<bool>>,
5089        observations: Mutex<VecDeque<LifecycleGithubObservation>>,
5090        registrations: AtomicUsize,
5091        remaining_runners: AtomicUsize,
5092        /// Every runner id `deregister` was asked to remove, in order. A count
5093        /// would not do: the assertions worth making are that the *right*
5094        /// registration was deleted and that it was deleted once.
5095        deregistrations: Mutex<Vec<u64>>,
5096        /// Set to make `deregister` answer `false`, standing for a GitHub that
5097        /// could not be reached at the moment the attempt concluded.
5098        deregistration_fails: AtomicBool,
5099        /// The journal to read *during* a registration, for the ordering
5100        /// assertion `02-target-architecture.md` makes: the slot lease is
5101        /// written "before package or GitHub effects". Reading it afterwards
5102        /// would pass even if the write happened second.
5103        journal: Mutex<Option<Arc<SqliteStore>>>,
5104        /// One entry per registration, in order.
5105        registration_facts: Mutex<Vec<RegistrationFact>>,
5106    }
5107
5108    /// What one JIT registration saw of the world at the moment it was issued.
5109    ///
5110    /// `runner_name` is what ties the other two fields to *one* attempt: with
5111    /// two allocators racing, "some slot was journalled" is a much weaker claim
5112    /// than "the slot this very request belongs to was journalled", and only the
5113    /// name distinguishes them.
5114    #[derive(Debug, Clone)]
5115    struct RegistrationFact {
5116        /// The persistent slots the journal already held.
5117        leased_slots: Vec<u16>,
5118        /// The `work_folder` the request carried.
5119        work_folder: String,
5120        /// The runner name the request carried, i.e. [`runner_name`] of the
5121        /// registering attempt.
5122        runner_name: String,
5123        journal_states: Vec<AttemptState>,
5124    }
5125
5126    impl FakeGithubLifecycle {
5127        fn fail(mut self, terminal: bool) -> Self {
5128            self.registration_failures
5129                .get_mut()
5130                .expect("unpoisoned")
5131                .push_back(terminal);
5132            self
5133        }
5134
5135        fn watch_journal(&self, store: Arc<SqliteStore>) {
5136            *self.journal.lock().unwrap() = Some(store);
5137        }
5138
5139        fn registration_facts(&self) -> Vec<RegistrationFact> {
5140            self.registration_facts.lock().unwrap().clone()
5141        }
5142
5143        fn observe(&self, observation: GithubRunnerObservation) {
5144            let observation = match observation {
5145                GithubRunnerObservation::Unreachable => LifecycleGithubObservation::unreachable(),
5146                GithubRunnerObservation::NotRegistered => {
5147                    LifecycleGithubObservation::not_registered()
5148                }
5149                GithubRunnerObservation::Registered { busy } => {
5150                    LifecycleGithubObservation::registered(73, busy)
5151                }
5152            };
5153            self.observations.lock().unwrap().push_back(observation);
5154        }
5155    }
5156
5157    #[async_trait]
5158    impl LifecycleGithub for FakeGithubLifecycle {
5159        async fn register(
5160            &self,
5161            _target: &ScaleTarget,
5162            request: &JitRunnerRequest,
5163            _cancel: &CancelToken,
5164        ) -> Result<JitRegistration, JitRequestFailure> {
5165            self.registrations.fetch_add(1, Ordering::SeqCst);
5166            if let Some(store) = self.journal.lock().unwrap().as_ref() {
5167                let attempts = store.attempts().expect("the journal is readable");
5168                let slots = attempts
5169                    .iter()
5170                    .filter_map(|attempt| attempt.workspace().slot_number())
5171                    .collect();
5172                self.registration_facts
5173                    .lock()
5174                    .unwrap()
5175                    .push(RegistrationFact {
5176                        leased_slots: slots,
5177                        work_folder: request.work_folder().to_string(),
5178                        runner_name: request.name().to_string(),
5179                        journal_states: attempts.iter().map(RunnerAttempt::state).collect(),
5180                    });
5181            }
5182            if let Some(terminal) = self.registration_failures.lock().unwrap().pop_front() {
5183                return Err(JitRequestFailure {
5184                    terminal,
5185                    reason: if terminal {
5186                        FailureReason::Other("GitHub refused JIT registration with 403".into())
5187                    } else {
5188                        FailureReason::JitRequestFailed
5189                    },
5190                    retry_after: None,
5191                });
5192            }
5193            self.remaining_runners.store(1, Ordering::SeqCst);
5194            Ok(JitRegistration::new(
5195                EncodedJitConfig::new(JIT),
5196                JitRunner {
5197                    id: 73,
5198                    name: request.name().to_string(),
5199                    os: "windows".into(),
5200                    status: "offline".into(),
5201                    busy: false,
5202                    runner_group_id: Some(1),
5203                    labels: request.labels().to_vec(),
5204                },
5205            ))
5206        }
5207
5208        async fn observe(
5209            &self,
5210            _target: &ScaleTarget,
5211            _attempt: AttemptId,
5212            _cancel: &CancelToken,
5213        ) -> LifecycleGithubObservation {
5214            let observation = self
5215                .observations
5216                .lock()
5217                .unwrap()
5218                .pop_front()
5219                .unwrap_or(LifecycleGithubObservation::not_registered());
5220            if observation.status == GithubRunnerObservation::NotRegistered {
5221                self.remaining_runners.store(0, Ordering::SeqCst);
5222            }
5223            observation
5224        }
5225
5226        async fn deregister(
5227            &self,
5228            _target: &ScaleTarget,
5229            runner_id: u64,
5230            _cancel: &CancelToken,
5231        ) -> bool {
5232            self.deregistrations.lock().unwrap().push(runner_id);
5233            if self.deregistration_fails.load(Ordering::SeqCst) {
5234                return false;
5235            }
5236            self.remaining_runners.store(0, Ordering::SeqCst);
5237            true
5238        }
5239    }
5240
5241    #[derive(Debug)]
5242    struct FakePackages {
5243        version: RunnerVersion,
5244        leases: Mutex<BTreeSet<AttemptId>>,
5245        materializations: AtomicUsize,
5246        materialization_failures: AtomicUsize,
5247        release_failures: AtomicUsize,
5248        releases: AtomicUsize,
5249        prunes: AtomicUsize,
5250        prune_currents: Mutex<Vec<RunnerVersion>>,
5251    }
5252
5253    impl Default for FakePackages {
5254        fn default() -> Self {
5255            Self {
5256                version: RunnerVersion::parse("2.330.0").unwrap(),
5257                leases: Mutex::new(BTreeSet::new()),
5258                materializations: AtomicUsize::new(0),
5259                materialization_failures: AtomicUsize::new(0),
5260                release_failures: AtomicUsize::new(0),
5261                releases: AtomicUsize::new(0),
5262                prunes: AtomicUsize::new(0),
5263                prune_currents: Mutex::new(Vec::new()),
5264            }
5265        }
5266    }
5267
5268    impl FakePackages {
5269        fn fail_materializations(&self, count: usize) {
5270            self.materialization_failures.store(count, Ordering::SeqCst);
5271        }
5272
5273        fn fail_releases(&self, count: usize) {
5274            self.release_failures.store(count, Ordering::SeqCst);
5275        }
5276    }
5277
5278    #[async_trait]
5279    impl RuntimePackages for FakePackages {
5280        async fn materialize(
5281            &self,
5282            attempt: &RunnerAttempt,
5283        ) -> Result<RunnerVersion, FailureReason> {
5284            self.materializations.fetch_add(1, Ordering::SeqCst);
5285            if self
5286                .materialization_failures
5287                .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |left| {
5288                    if left > 0 { Some(left - 1) } else { None }
5289                })
5290                .is_ok()
5291            {
5292                return Err(FailureReason::Other(
5293                    "runner package materialization failed transiently".into(),
5294                ));
5295            }
5296            fs::create_dir_all(attempt.runtime_path()).unwrap();
5297            fs::write(attempt.runtime_path().join("runner-package"), b"verified").unwrap();
5298            self.leases.lock().unwrap().insert(attempt.id);
5299            Ok(self.version.clone())
5300        }
5301
5302        fn release(&self, attempt: AttemptId) -> Result<(), FailureReason> {
5303            if self
5304                .release_failures
5305                .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |left| {
5306                    if left > 0 { Some(left - 1) } else { None }
5307                })
5308                .is_ok()
5309            {
5310                return Err(FailureReason::Other(
5311                    "runner package lease could not be released".into(),
5312                ));
5313            }
5314            self.leases.lock().unwrap().remove(&attempt);
5315            self.releases.fetch_add(1, Ordering::SeqCst);
5316            Ok(())
5317        }
5318
5319        fn prune_obsolete_guarded(
5320            &self,
5321            _authority: PruneAuthority<'_>,
5322            current: &RunnerVersion,
5323            _attempts: &[RunnerAttempt],
5324        ) -> Result<(), FailureReason> {
5325            self.prunes.fetch_add(1, Ordering::SeqCst);
5326            self.prune_currents.lock().unwrap().push(current.clone());
5327            Ok(())
5328        }
5329    }
5330
5331    #[derive(Debug, Default)]
5332    struct FakeProcesses {
5333        alive: AtomicBool,
5334        completed_successfully: AtomicBool,
5335        spawns: AtomicUsize,
5336        spawn_failures: AtomicUsize,
5337        live_spawn_failure: AtomicBool,
5338        terminations: AtomicUsize,
5339        intent: AtomicBool,
5340        intent_failure: AtomicBool,
5341        actions: Mutex<Vec<&'static str>>,
5342        saw_secret: AtomicBool,
5343    }
5344
5345    impl FakeProcesses {
5346        fn fail_spawns(&self, count: usize) {
5347            self.spawn_failures.store(count, Ordering::SeqCst);
5348        }
5349
5350        fn fail_spawn_with_live_child(&self) {
5351            self.live_spawn_failure.store(true, Ordering::SeqCst);
5352        }
5353
5354        fn set_alive(&self, alive: bool) {
5355            self.alive.store(alive, Ordering::SeqCst);
5356        }
5357
5358        fn finish_successfully(&self) {
5359            self.completed_successfully.store(true, Ordering::SeqCst);
5360            self.alive.store(false, Ordering::SeqCst);
5361        }
5362
5363        fn fail_intent(&self) {
5364            self.intent_failure.store(true, Ordering::SeqCst);
5365        }
5366    }
5367
5368    impl ProcessSupervisor for FakeProcesses {
5369        fn spawn(
5370            &self,
5371            attempt: &RunnerAttempt,
5372            config: &EncodedJitConfig,
5373        ) -> Result<u32, ProcessStartFailure> {
5374            self.spawns.fetch_add(1, Ordering::SeqCst);
5375            // Model the production handoff on both paths: the sensitive file is
5376            // scoped to this call and absent when it returns.
5377            let handoff = RestrictiveHandoff::create(
5378                attempt.runtime_path(),
5379                SecretString::from(config.expose().to_owned()),
5380            )
5381            .unwrap();
5382            self.saw_secret
5383                .store(config.expose() == JIT, Ordering::SeqCst);
5384            let handoff_path = handoff.path().to_path_buf();
5385            let failing = self
5386                .spawn_failures
5387                .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |left| {
5388                    if left > 0 { Some(left - 1) } else { None }
5389                })
5390                .is_ok();
5391            drop(handoff);
5392            assert!(!handoff_path.exists(), "handoff must be absent on return");
5393            if self.live_spawn_failure.swap(false, Ordering::SeqCst) {
5394                self.alive.store(true, Ordering::SeqCst);
5395                return Err(ProcessStartFailure::after_spawn_live(4242));
5396            }
5397            if failing {
5398                return Err(ProcessStartFailure::before_spawn(
5399                    FailureReason::ProcessStartFailed,
5400                ));
5401            }
5402            self.alive.store(true, Ordering::SeqCst);
5403            Ok(4242)
5404        }
5405
5406        fn is_alive(&self, _attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
5407            self.actions.lock().unwrap().push("observe_process");
5408            Ok(self.alive.load(Ordering::SeqCst))
5409        }
5410
5411        fn recovered_pid(&self, _attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason> {
5412            Ok(self.alive.load(Ordering::SeqCst).then_some(4242))
5413        }
5414
5415        fn completed_successfully(&self, _attempt: &RunnerAttempt) -> bool {
5416            self.completed_successfully.load(Ordering::SeqCst)
5417        }
5418
5419        fn record_terminate_intent(&self, _attempt: &RunnerAttempt) -> Result<(), FailureReason> {
5420            self.actions.lock().unwrap().push("terminate_intent");
5421            if self.intent_failure.load(Ordering::SeqCst) {
5422                return Err(FailureReason::Other(
5423                    "terminate intent directory sync failed".into(),
5424                ));
5425            }
5426            self.intent.store(true, Ordering::SeqCst);
5427            Ok(())
5428        }
5429
5430        fn has_terminate_intent(&self, _attempt: &RunnerAttempt) -> bool {
5431            self.intent.load(Ordering::SeqCst)
5432        }
5433
5434        fn terminate(&self, _attempt: &RunnerAttempt) -> Result<(), FailureReason> {
5435            assert!(
5436                self.intent.load(Ordering::SeqCst),
5437                "the durable intent must exist before signalling"
5438            );
5439            self.actions.lock().unwrap().push("terminate");
5440            self.terminations.fetch_add(1, Ordering::SeqCst);
5441            self.alive.store(false, Ordering::SeqCst);
5442            Ok(())
5443        }
5444    }
5445
5446    #[derive(Debug, Default)]
5447    struct FakeIsolatedProvider {
5448        resources: Mutex<BTreeMap<AttemptId, (EnvironmentIdentity, EnvironmentState)>>,
5449        extra_resources: Mutex<Vec<EnvironmentIdentity>>,
5450        unsupported_host: AtomicBool,
5451        permission_denied: AtomicBool,
5452        incompatible_image: AtomicBool,
5453        prepare_failure: AtomicBool,
5454        recovery_unavailable: AtomicBool,
5455        destroy_deferred: AtomicBool,
5456        handoffs: AtomicUsize,
5457        completed_successfully: AtomicBool,
5458        native_calls: AtomicUsize,
5459        actions: Mutex<Vec<&'static str>>,
5460    }
5461
5462    impl FakeIsolatedProvider {
5463        fn identity(attempt: &RunnerAttempt, host: HostId) -> EnvironmentIdentity {
5464            let AttemptExecution::Isolated {
5465                provider_kind,
5466                resolved_image,
5467                generation,
5468                ..
5469            } = attempt.execution()
5470            else {
5471                panic!("isolated intent required")
5472            };
5473            EnvironmentIdentity::Isolated {
5474                host,
5475                attempt: attempt.id,
5476                provider_kind: *provider_kind,
5477                environment_id: format!("environment-{}", attempt.id),
5478                resolved_image: resolved_image.clone(),
5479                generation: generation.clone(),
5480            }
5481        }
5482
5483        fn resource_count(&self) -> usize {
5484            self.resources.lock().unwrap().len()
5485        }
5486    }
5487
5488    impl ExecutionProvider for FakeIsolatedProvider {
5489        fn probe(&self, policy: &ScalePolicy) -> ProviderCapability {
5490            if self.unsupported_host.load(Ordering::SeqCst) {
5491                ProviderCapability::Unsupported
5492            } else if self.permission_denied.load(Ordering::SeqCst) {
5493                ProviderCapability::PermissionDenied
5494            } else if policy.execution_policy().is_native() {
5495                ProviderCapability::Unsupported
5496            } else {
5497                ProviderCapability::Ready
5498            }
5499        }
5500
5501        fn resolve(
5502            &self,
5503            policy: &ScalePolicy,
5504        ) -> Result<Option<ResolvedEnvironment>, FailureReason> {
5505            let runner_manager_domain::execution::ExecutionPolicy::Isolated { image, .. } =
5506                policy.execution_policy()
5507            else {
5508                return Err(FailureReason::Other("isolated policy required".into()));
5509            };
5510            Ok(Some(ResolvedEnvironment {
5511                provider_kind: Backend::Oci,
5512                image: if self.incompatible_image.load(Ordering::SeqCst) {
5513                    ImageReference::new("vm-version:wrong-provider")
5514                        .expect("valid but incompatible")
5515                } else {
5516                    image.clone()
5517                },
5518            }))
5519        }
5520
5521        fn prepare(
5522            &self,
5523            attempt: &RunnerAttempt,
5524            policy: &ScalePolicy,
5525        ) -> Result<PreparedEnvironment, FailureReason> {
5526            assert_eq!(
5527                attempt.state(),
5528                AttemptState::Preparing,
5529                "intent must be durable before provider effect"
5530            );
5531            self.actions.lock().unwrap().push("prepare");
5532            let identity = Self::identity(attempt, policy.host_id);
5533            self.resources
5534                .lock()
5535                .unwrap()
5536                .insert(attempt.id, (identity.clone(), EnvironmentState::Starting));
5537            if self.prepare_failure.swap(false, Ordering::SeqCst) {
5538                return Err(FailureReason::Other("provider-secret-needle".into()));
5539            }
5540            Ok(PreparedEnvironment::isolated(attempt.id, identity))
5541        }
5542
5543        fn start(
5544            &self,
5545            prepared: PreparedEnvironment,
5546            attempt: &RunnerAttempt,
5547            handoff: OneTimeJitHandoff<'_>,
5548        ) -> Result<EnvironmentIdentity, ProcessStartFailure> {
5549            assert_eq!(prepared.attempt, attempt.id);
5550            assert_eq!(attempt.state(), AttemptState::JitReceived);
5551            assert_eq!(handoff.consume().expose(), JIT);
5552            self.handoffs.fetch_add(1, Ordering::SeqCst);
5553            self.actions.lock().unwrap().push("start");
5554            let mut resources = self.resources.lock().unwrap();
5555            let (identity, state) = resources.get_mut(&attempt.id).expect("prepared resource");
5556            *state = EnvironmentState::Running;
5557            Ok(identity.clone())
5558        }
5559
5560        fn inspect(&self, attempt: &RunnerAttempt) -> Result<EnvironmentState, FailureReason> {
5561            self.actions.lock().unwrap().push("inspect");
5562            Ok(self
5563                .resources
5564                .lock()
5565                .unwrap()
5566                .get(&attempt.id)
5567                .map_or(EnvironmentState::Missing, |(_, state)| *state))
5568        }
5569
5570        fn recover(&self, attempt: &RunnerAttempt) -> Result<EnvironmentState, FailureReason> {
5571            if self.recovery_unavailable.load(Ordering::SeqCst) {
5572                return Err(FailureReason::Other(
5573                    "provider discovery unavailable".into(),
5574                ));
5575            }
5576            self.inspect(attempt)
5577        }
5578
5579        fn owns(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
5580            Ok(self
5581                .resources
5582                .lock()
5583                .unwrap()
5584                .get(&attempt.id)
5585                .is_some_and(|(identity, _)| {
5586                    identity_matches_intent(attempt_host(identity), attempt, identity)
5587                }))
5588        }
5589
5590        fn stop(&self, attempt: &RunnerAttempt) -> Result<ProviderStop, FailureReason> {
5591            assert!(self.owns(attempt)?);
5592            self.actions.lock().unwrap().push("stop");
5593            if let Some((_, state)) = self.resources.lock().unwrap().get_mut(&attempt.id) {
5594                *state = EnvironmentState::Exited;
5595            }
5596            Ok(ProviderStop::Stopped)
5597        }
5598
5599        fn destroy(&self, attempt: &RunnerAttempt) -> Result<ProviderDestroy, FailureReason> {
5600            assert!(self.owns(attempt)?);
5601            self.actions.lock().unwrap().push("destroy");
5602            if self.destroy_deferred.load(Ordering::SeqCst) {
5603                return Ok(ProviderDestroy::Deferred("provider busy"));
5604            }
5605            self.resources.lock().unwrap().remove(&attempt.id);
5606            Ok(ProviderDestroy::Destroyed)
5607        }
5608
5609        fn enumerate_owned(&self, host: HostId) -> Vec<EnvironmentIdentity> {
5610            let mut owned = self
5611                .resources
5612                .lock()
5613                .unwrap()
5614                .values()
5615                .filter(|(identity, _)| attempt_host(identity) == host)
5616                .map(|(identity, _)| identity.clone())
5617                .collect::<Vec<_>>();
5618            owned.extend(
5619                self.extra_resources
5620                    .lock()
5621                    .unwrap()
5622                    .iter()
5623                    .filter(|identity| attempt_host(identity) == host)
5624                    .cloned(),
5625            );
5626            owned
5627        }
5628
5629        fn diagnostics(&self, _attempt: &RunnerAttempt) -> Vec<ProviderDiagnostic> {
5630            vec![ProviderDiagnostic::CleanupDeferred; 40]
5631        }
5632
5633        fn spawn(
5634            &self,
5635            _attempt: &RunnerAttempt,
5636            _config: &EncodedJitConfig,
5637        ) -> Result<u32, ProcessStartFailure> {
5638            self.native_calls.fetch_add(1, Ordering::SeqCst);
5639            panic!("isolated provider must never spawn natively")
5640        }
5641        fn is_alive(&self, _attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
5642            panic!("native fallback")
5643        }
5644        fn recovered_pid(&self, _attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason> {
5645            panic!("native fallback")
5646        }
5647        fn completed_successfully(&self, _attempt: &RunnerAttempt) -> bool {
5648            self.completed_successfully.load(Ordering::SeqCst)
5649        }
5650        fn record_terminate_intent(&self, _attempt: &RunnerAttempt) -> Result<(), FailureReason> {
5651            panic!("native fallback")
5652        }
5653        fn has_terminate_intent(&self, _attempt: &RunnerAttempt) -> bool {
5654            false
5655        }
5656        fn terminate(&self, _attempt: &RunnerAttempt) -> Result<(), FailureReason> {
5657            panic!("native fallback")
5658        }
5659    }
5660
5661    fn attempt_host(identity: &EnvironmentIdentity) -> HostId {
5662        let EnvironmentIdentity::Isolated { host, .. } = identity else {
5663            panic!("isolated identity")
5664        };
5665        *host
5666    }
5667
5668    #[derive(Debug, Default)]
5669    struct FakeDemand {
5670        answers: Mutex<VecDeque<bool>>,
5671    }
5672
5673    impl FakeDemand {
5674        fn answering(answers: impl IntoIterator<Item = bool>) -> Self {
5675            Self {
5676                answers: Mutex::new(answers.into_iter().collect()),
5677            }
5678        }
5679    }
5680
5681    #[async_trait]
5682    impl DemandPersistence for FakeDemand {
5683        async fn persists(&self, _policy: PolicyId) -> bool {
5684            self.answers.lock().unwrap().pop_front().unwrap_or(true)
5685        }
5686    }
5687
5688    #[derive(Debug, Default)]
5689    struct FakeDelay(Mutex<Vec<Duration>>);
5690
5691    #[async_trait]
5692    impl RetryDelay for FakeDelay {
5693        async fn wait(&self, duration: Duration) {
5694            self.0.lock().unwrap().push(duration);
5695        }
5696    }
5697
5698    #[tokio::test]
5699    async fn live_capacity_reads_the_updated_sqlite_host_and_rejects_a_missing_host() {
5700        let mut harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
5701        let path = harness._root.path().join("live-capacity.sqlite3");
5702        harness.store = Arc::new(SqliteStore::open(&path).unwrap());
5703        harness.store.put_host(&harness.host).unwrap();
5704        harness.launcher = harness.restart();
5705        assert_eq!(
5706            harness.launcher.host_capacity(&harness.host).await.unwrap(),
5707            harness.host.host_capacity
5708        );
5709        // A separate CLI/TUI connection updates the journal while the existing
5710        // launcher keeps its original store connection and startup host.
5711        let writer = SqliteStore::open(&path).unwrap();
5712        let mut updated = harness.host.clone();
5713        updated.host_capacity = std::num::NonZeroU16::new(12).unwrap();
5714        writer.put_host(&updated).unwrap();
5715        assert_eq!(
5716            harness
5717                .launcher
5718                .host_capacity(&harness.host)
5719                .await
5720                .unwrap()
5721                .get(),
5722            12
5723        );
5724
5725        updated.host_capacity = std::num::NonZeroU16::new(2).unwrap();
5726        writer.put_host(&updated).unwrap();
5727        assert_eq!(
5728            harness
5729                .launcher
5730                .host_capacity(&harness.host)
5731                .await
5732                .unwrap()
5733                .get(),
5734            2
5735        );
5736
5737        let mut missing = harness.host.clone();
5738        missing.id = HostId::from_u128(999);
5739        assert_eq!(
5740            harness.launcher.host_capacity(&missing).await,
5741            Err(crate::reconcile::HostCapacityError::MissingHost)
5742        );
5743    }
5744
5745    struct Harness {
5746        _root: tempfile::TempDir,
5747        app_paths: runner_manager_platform::paths::AppPaths,
5748        launcher: LifecycleLauncher,
5749        demand: Arc<dyn DemandPersistence>,
5750        store: Arc<SqliteStore>,
5751        github: Arc<FakeGithubLifecycle>,
5752        packages: Arc<FakePackages>,
5753        processes: Arc<FakeProcesses>,
5754        clock: Arc<FakeClock>,
5755        events: Arc<AttemptEventLog>,
5756        reconcile_events: Arc<crate::reconcile::EventLog>,
5757        delay: Arc<FakeDelay>,
5758        host: runner_manager_domain::model::Host,
5759        policy: ScalePolicy,
5760        allocation_lock: InProcessAllocationLock,
5761        /// The repository persistent root, once one is configured.
5762        workspace_root: Option<LocalAbsolutePath>,
5763    }
5764
5765    impl Harness {
5766        fn new(github: FakeGithubLifecycle, demand: Arc<dyn DemandPersistence>) -> Self {
5767            let root = tempfile::tempdir().unwrap();
5768            let paths = runner_manager_platform::paths::AppPaths::rooted_at(root.path());
5769            paths.create_all().unwrap();
5770            let policy = fixtures::policy()
5771                .repository("octo/repo")
5772                .autoscale("home", 2)
5773                .active()
5774                .build();
5775            // A unit harness must never inspect or write the workstation's
5776            // production runner root. On Windows that is C:\rman, which is
5777            // intentionally inaccessible to an ordinary account after a boot
5778            // service is installed. Using it made the same suite pass on a
5779            // clean CI image and fail on a correctly secured developer host.
5780            let host_root = root.path().join("host-root");
5781            fs::create_dir_all(&host_root).unwrap();
5782            let mut host = fixtures::host().build();
5783            host.runner_root_override = Some(
5784                LocalAbsolutePath::new(host_root.to_str().expect("a UTF-8 temporary path"))
5785                    .expect("a local absolute host root"),
5786            );
5787            let store = Arc::new(SqliteStore::open_in_memory().unwrap());
5788            store.put_host(&host).unwrap();
5789            let github = Arc::new(github);
5790            let packages = Arc::new(FakePackages::default());
5791            let processes = Arc::new(FakeProcesses::default());
5792            let clock = Arc::new(FakeClock::default());
5793            let events = Arc::new(AttemptEventLog::default());
5794            let reconcile_events = Arc::new(crate::reconcile::EventLog::new());
5795            let delay = Arc::new(FakeDelay::default());
5796            let ports = LifecyclePorts {
5797                store: Arc::clone(&store) as Arc<dyn Store>,
5798                github: Arc::clone(&github) as Arc<dyn LifecycleGithub>,
5799                packages: Arc::clone(&packages) as Arc<dyn RuntimePackages>,
5800                processes: Arc::clone(&processes) as Arc<dyn ProcessSupervisor>,
5801                clock: Arc::clone(&clock) as Arc<dyn Clock>,
5802                demand: Arc::clone(&demand),
5803                delay: Arc::clone(&delay) as Arc<dyn RetryDelay>,
5804                events: Arc::clone(&events) as Arc<dyn AttemptEventSink>,
5805                reconcile_events: Arc::clone(&reconcile_events) as Arc<dyn EventSink>,
5806            };
5807            let launcher = Self::launcher_over(policy.host_id, &paths, ports);
5808            Self {
5809                _root: root,
5810                app_paths: paths,
5811                launcher,
5812                demand,
5813                store,
5814                github,
5815                packages,
5816                processes,
5817                clock,
5818                events,
5819                reconcile_events,
5820                delay,
5821                host,
5822                policy,
5823                allocation_lock: InProcessAllocationLock::new(),
5824                workspace_root: None,
5825            }
5826        }
5827
5828        /// Put disposable attempts under a host root of this harness's own.
5829        ///
5830        /// Without it the launcher resolves the *platform* default, which on
5831        /// Windows is `%SystemDrive%\rman` — a real directory on the machine
5832        /// running the suite. Every test added by `c2` places its files inside
5833        /// its own temporary directory instead.
5834        fn with_host_runner_root(mut self) -> Self {
5835            let host_root = self.host_root();
5836            fs::create_dir_all(&host_root).unwrap();
5837            self.host.runner_root_override = Some(
5838                LocalAbsolutePath::new(host_root.to_str().expect("a UTF-8 temporary path"))
5839                    .expect("a local absolute host root"),
5840            );
5841            self.store.put_host(&self.host).unwrap();
5842            self
5843        }
5844
5845        /// Opt this harness's repository into a persistent workspace (D4).
5846        fn with_persistent_workspace(mut self, capacity: u16) -> Self {
5847            self = self.with_host_runner_root();
5848            let root = self._root.path().join("persist");
5849            let root = LocalAbsolutePath::new(root.to_str().expect("a UTF-8 temporary path"))
5850                .expect("a local absolute workspace root");
5851            self.policy = fixtures::policy()
5852                .repository("octo/repo")
5853                .autoscale("home", capacity)
5854                .active()
5855                .build();
5856            self.policy
5857                .set_workspace_policy(
5858                    WorkspacePolicy::persistent(root.clone(), TargetScope::Repository)
5859                        .expect("a repository may be persistent"),
5860                )
5861                .expect("a repository may be persistent");
5862            self.workspace_root = Some(root);
5863            // The journal's copy, so that cleanup's cross-check against a
5864            // surviving policy is exercised rather than skipped.
5865            self.store.insert_policy(&self.policy).unwrap();
5866            self
5867        }
5868
5869        fn workspace_root(&self) -> &LocalAbsolutePath {
5870            self.workspace_root
5871                .as_ref()
5872                .expect("this harness configured a persistent workspace")
5873        }
5874
5875        fn slot_path(&self, slot: u16) -> PathBuf {
5876            self.workspace_root().as_path().join(format!("s{slot}"))
5877        }
5878
5879        fn host_root(&self) -> PathBuf {
5880            self._root.path().join("host-root")
5881        }
5882
5883        fn attempt(&self, id: AttemptId) -> RunnerAttempt {
5884            self.store
5885                .attempt(id)
5886                .unwrap()
5887                .expect("the attempt is journalled")
5888        }
5889
5890        /// Conclude an attempt and run the real cleanup over it.
5891        fn conclude(&self, id: AttemptId) -> RunnerAttempt {
5892            let mut attempt = self.attempt(id);
5893            attempt
5894                .conclude(
5895                    AttemptOutcome::failed(FailureReason::ProcessExitedUnexpectedly),
5896                    self.clock.now(),
5897                )
5898                .unwrap();
5899            self.store.record_attempt(&attempt).unwrap();
5900            attempt
5901        }
5902
5903        async fn cleanup_retaining_work(&self, id: AttemptId) {
5904            self.conclude(id);
5905            self.launcher
5906                .clean(id)
5907                .await
5908                .expect("the slot is scrubbed and the lease released");
5909        }
5910
5911        /// The launcher configuration every launcher in this harness shares, so
5912        /// that the one a restart mints cannot drift from the original.
5913        fn launcher_over(
5914            host: HostId,
5915            paths: &runner_manager_platform::paths::AppPaths,
5916            ports: LifecyclePorts,
5917        ) -> LifecycleLauncher {
5918            LifecycleLauncher::new(
5919                host,
5920                paths.clone(),
5921                paths.logs_dir(),
5922                1,
5923                RecoveryTimeouts::new(
5924                    Elapsed::seconds(10),
5925                    Elapsed::seconds(10),
5926                    Elapsed::seconds(10),
5927                ),
5928                RetryPolicy::bounded(3, Duration::from_millis(10), Duration::from_millis(25)),
5929                ports,
5930            )
5931        }
5932
5933        /// The same journal, the same directories, a launcher that remembers
5934        /// nothing — which is what a daemon restart is.
5935        fn restart(&self) -> LifecycleLauncher {
5936            Self::launcher_over(
5937                self.policy.host_id,
5938                &self.app_paths,
5939                LifecyclePorts {
5940                    store: Arc::clone(&self.store) as Arc<dyn Store>,
5941                    github: Arc::clone(&self.github) as Arc<dyn LifecycleGithub>,
5942                    packages: Arc::clone(&self.packages) as Arc<dyn RuntimePackages>,
5943                    processes: Arc::clone(&self.processes) as Arc<dyn ProcessSupervisor>,
5944                    clock: Arc::clone(&self.clock) as Arc<dyn Clock>,
5945                    demand: Arc::clone(&self.demand),
5946                    delay: Arc::clone(&self.delay) as Arc<dyn RetryDelay>,
5947                    events: Arc::clone(&self.events) as Arc<dyn AttemptEventSink>,
5948                    reconcile_events: Arc::clone(&self.reconcile_events) as Arc<dyn EventSink>,
5949                },
5950            )
5951        }
5952
5953        async fn ready(&self) {
5954            self.launcher
5955                .recover_startup(std::slice::from_ref(&self.policy))
5956                .await
5957                .unwrap();
5958        }
5959
5960        async fn launch(&self) -> RunnerAttempt {
5961            self.launch_result().await.unwrap()
5962        }
5963
5964        async fn launch_result(&self) -> Result<RunnerAttempt, LaunchFailure> {
5965            self.launch_after_waiting(Duration::ZERO).await
5966        }
5967
5968        async fn launch_after_waiting(
5969            &self,
5970            lock_wait: Duration,
5971        ) -> Result<RunnerAttempt, LaunchFailure> {
5972            let guard = self.allocation_lock.acquire().await.unwrap();
5973            self.launcher
5974                .launch(LaunchRequest {
5975                    host: &self.host,
5976                    policy: &self.policy,
5977                    allocation_guard: &guard,
5978                    lock_wait,
5979                })
5980                .await
5981        }
5982
5983        fn only_attempt(&self) -> RunnerAttempt {
5984            self.store.attempts().unwrap().into_iter().next().unwrap()
5985        }
5986    }
5987
5988    fn isolated_harness() -> Harness {
5989        use runner_manager_domain::execution::{ExecutionPolicy, ResourceLimits};
5990        let mut harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
5991            .with_host_runner_root();
5992        let image =
5993            ImageReference::new(format!("registry.example/runner@sha256:{}", "a".repeat(64)))
5994                .expect("pinned image");
5995        harness
5996            .policy
5997            .set_execution_policy(ExecutionPolicy::Isolated {
5998                backend: Backend::Oci,
5999                image,
6000                resources: ResourceLimits {
6001                    cpu_millis: 1000,
6002                    memory_mib: 1024,
6003                    disk_mib: 4096,
6004                },
6005            })
6006            .expect("isolated policy");
6007        harness.github.watch_journal(Arc::clone(&harness.store));
6008        harness
6009    }
6010
6011    fn launcher_with_isolated(
6012        harness: &Harness,
6013        provider: Arc<FakeIsolatedProvider>,
6014    ) -> LifecycleLauncher {
6015        Harness::launcher_over(
6016            harness.policy.host_id,
6017            &harness.app_paths,
6018            LifecyclePorts {
6019                store: Arc::clone(&harness.store) as Arc<dyn Store>,
6020                github: Arc::clone(&harness.github) as Arc<dyn LifecycleGithub>,
6021                packages: Arc::clone(&harness.packages) as Arc<dyn RuntimePackages>,
6022                processes: provider as Arc<dyn ExecutionProvider>,
6023                clock: Arc::clone(&harness.clock) as Arc<dyn Clock>,
6024                demand: Arc::clone(&harness.demand),
6025                delay: Arc::clone(&harness.delay) as Arc<dyn RetryDelay>,
6026                events: Arc::clone(&harness.events) as Arc<dyn AttemptEventSink>,
6027                reconcile_events: Arc::clone(&harness.reconcile_events) as Arc<dyn EventSink>,
6028            },
6029        )
6030    }
6031
6032    #[tokio::test]
6033    async fn isolated_policy_and_journal_never_enter_native_provider_or_jit() {
6034        use runner_manager_domain::execution::{
6035            AttemptExecution, Backend, ExecutionPolicy, ImageReference, ResourceLimits,
6036        };
6037
6038        let mut harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
6039            .with_host_runner_root();
6040        let image =
6041            ImageReference::new(format!("registry.example/runner@sha256:{}", "a".repeat(64)))
6042                .expect("pinned image");
6043        harness
6044            .policy
6045            .set_execution_policy(ExecutionPolicy::Isolated {
6046                backend: Backend::Oci,
6047                image: image.clone(),
6048                resources: ResourceLimits {
6049                    cpu_millis: 1000,
6050                    memory_mib: 1024,
6051                    disk_mib: 4096,
6052                },
6053            })
6054            .expect("valid isolated profile");
6055
6056        harness.ready().await;
6057        assert_eq!(
6058            harness.processes.probe(&harness.policy),
6059            ProviderCapability::Unsupported
6060        );
6061        let failure = harness
6062            .launch_result()
6063            .await
6064            .expect_err("provider is unavailable");
6065        assert_eq!(
6066            failure.reason,
6067            FailureReason::IsolationProvider(IsolationProviderFailure::Unsupported)
6068        );
6069
6070        let runtime = harness.host_root().join("isolated-owned-environment");
6071        fs::create_dir_all(&runtime).expect("provider resource fixture");
6072        fs::write(runtime.join("owned-marker"), "non-secret").expect("fixture marker");
6073        let mut attempt = RunnerAttempt::allocate(
6074            AttemptId::from_u128(0x123),
6075            harness.policy.id,
6076            runtime.clone(),
6077            harness.clock.now(),
6078        );
6079        attempt
6080            .allocate_execution(AttemptExecution::Isolated {
6081                provider_kind: Backend::Oci,
6082                environment_id: None,
6083                resolved_image: image,
6084                generation: "generation-123".into(),
6085            })
6086            .expect("isolated intent");
6087        attempt
6088            .prepared_environment("environment-123".into())
6089            .expect("prepared identity");
6090        harness
6091            .store
6092            .record_attempt(&attempt)
6093            .expect("durable isolated journal");
6094
6095        harness
6096            .launcher
6097            .recover_startup(std::slice::from_ref(&harness.policy))
6098            .await
6099            .expect("unsupported isolated cleanup is profile-local");
6100        let failure = harness
6101            .launch_result()
6102            .await
6103            .expect_err("provider unavailable for this policy");
6104        assert_eq!(
6105            failure.reason,
6106            FailureReason::IsolationProvider(IsolationProviderFailure::Unsupported)
6107        );
6108        harness
6109            .launcher
6110            .supervise(&harness.policy)
6111            .await
6112            .expect("unavailable attempt is deferred");
6113        assert!(harness.launcher.clean(attempt.id).await.is_err());
6114        assert_eq!(
6115            harness.store.attempt(attempt.id).unwrap().unwrap().state(),
6116            AttemptState::CleanupDeferred
6117        );
6118        assert!(matches!(
6119            harness.launcher.recover_startup(&[]).await,
6120            Err(LifecycleError::RecoveryIncomplete)
6121        ));
6122        assert!(
6123            runtime.join("owned-marker").is_file(),
6124            "native cleanup touched the isolated environment"
6125        );
6126        assert!(
6127            harness.processes.actions.lock().unwrap().is_empty(),
6128            "native recovery inspected an isolated identity"
6129        );
6130        assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 0);
6131        assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
6132        assert_eq!(harness.packages.materializations.load(Ordering::SeqCst), 0);
6133    }
6134
6135    #[tokio::test]
6136    async fn isolated_prepare_is_durable_before_jit_and_start_uses_one_handoff() {
6137        let harness = isolated_harness();
6138        let provider = Arc::new(FakeIsolatedProvider::default());
6139        let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
6140        launcher
6141            .recover_startup(std::slice::from_ref(&harness.policy))
6142            .await
6143            .expect("ready");
6144        let guard = harness.allocation_lock.acquire().await.unwrap();
6145        let attempt = launcher
6146            .launch(LaunchRequest {
6147                host: &harness.host,
6148                policy: &harness.policy,
6149                allocation_guard: &guard,
6150                lock_wait: Duration::ZERO,
6151            })
6152            .await
6153            .expect("isolated launch");
6154        assert_eq!(attempt.state(), AttemptState::Starting);
6155        assert_eq!(provider.resource_count(), 1);
6156        assert_eq!(provider.handoffs.load(Ordering::SeqCst), 1);
6157        assert_eq!(provider.native_calls.load(Ordering::SeqCst), 0);
6158        assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 1);
6159        let fact = harness
6160            .github
6161            .registration_facts()
6162            .pop()
6163            .expect("JIT registration");
6164        assert_eq!(
6165            fact.journal_states,
6166            vec![AttemptState::Prepared],
6167            "provider readiness preceded GitHub JIT"
6168        );
6169        assert_eq!(
6170            harness
6171                .events
6172                .events()
6173                .into_iter()
6174                .filter_map(|event| match event {
6175                    AttemptEvent::State { state, .. } => Some(state),
6176                    _ => None,
6177                })
6178                .collect::<Vec<_>>(),
6179            vec![
6180                AttemptState::Allocated,
6181                AttemptState::Preparing,
6182                AttemptState::Prepared,
6183                AttemptState::JitReceived,
6184                AttemptState::Starting
6185            ]
6186        );
6187        let restarted = launcher_with_isolated(&harness, Arc::clone(&provider));
6188        restarted
6189            .recover_startup(std::slice::from_ref(&harness.policy))
6190            .await
6191            .expect("adopt live sandbox");
6192        assert_eq!(provider.resource_count(), 1);
6193        assert_eq!(
6194            harness.github.registrations.load(Ordering::SeqCst),
6195            1,
6196            "restart cannot issue duplicate JIT"
6197        );
6198        harness
6199            .github
6200            .observe(GithubRunnerObservation::Registered { busy: true });
6201        restarted
6202            .supervise(&harness.policy)
6203            .await
6204            .expect("job observed");
6205        assert_eq!(
6206            harness.store.attempt(attempt.id).unwrap().unwrap().state(),
6207            AttemptState::Busy
6208        );
6209        provider
6210            .resources
6211            .lock()
6212            .unwrap()
6213            .get_mut(&attempt.id)
6214            .unwrap()
6215            .1 = EnvironmentState::Exited;
6216        harness
6217            .github
6218            .observe(GithubRunnerObservation::NotRegistered);
6219        restarted
6220            .supervise(&harness.policy)
6221            .await
6222            .expect("terminal cleanup");
6223        assert_eq!(provider.resource_count(), 0);
6224        assert_eq!(
6225            harness.store.attempt(attempt.id).unwrap().unwrap().state(),
6226            AttemptState::Cleaned
6227        );
6228    }
6229
6230    #[tokio::test]
6231    async fn attached_isolated_job_exit_is_recorded_as_completed() {
6232        let harness = isolated_harness();
6233        let provider = Arc::new(FakeIsolatedProvider::default());
6234        let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
6235        launcher
6236            .recover_startup(std::slice::from_ref(&harness.policy))
6237            .await
6238            .unwrap();
6239        let guard = harness.allocation_lock.acquire().await.unwrap();
6240        let attempt = launcher
6241            .launch(LaunchRequest {
6242                host: &harness.host,
6243                policy: &harness.policy,
6244                allocation_guard: &guard,
6245                lock_wait: Duration::ZERO,
6246            })
6247            .await
6248            .unwrap();
6249        harness
6250            .github
6251            .observe(GithubRunnerObservation::Registered { busy: true });
6252        launcher.supervise(&harness.policy).await.unwrap();
6253        provider
6254            .resources
6255            .lock()
6256            .unwrap()
6257            .get_mut(&attempt.id)
6258            .unwrap()
6259            .1 = EnvironmentState::Exited;
6260        provider
6261            .completed_successfully
6262            .store(true, Ordering::SeqCst);
6263        harness
6264            .github
6265            .observe(GithubRunnerObservation::NotRegistered);
6266        launcher.supervise(&harness.policy).await.unwrap();
6267        let cleaned = harness.store.attempt(attempt.id).unwrap().unwrap();
6268        assert_eq!(cleaned.outcome(), Some(&AttemptOutcome::CompletedJob));
6269        assert_eq!(cleaned.state(), AttemptState::Cleaned);
6270    }
6271
6272    #[tokio::test]
6273    async fn isolated_prepare_failure_stays_local_and_crash_gap_recovers_owned_resource() {
6274        let harness = isolated_harness();
6275        let provider = Arc::new(FakeIsolatedProvider::default());
6276        provider.prepare_failure.store(true, Ordering::SeqCst);
6277        let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
6278        launcher
6279            .recover_startup(std::slice::from_ref(&harness.policy))
6280            .await
6281            .expect("ready");
6282        let guard = harness.allocation_lock.acquire().await.unwrap();
6283        let failure = launcher
6284            .launch(LaunchRequest {
6285                host: &harness.host,
6286                policy: &harness.policy,
6287                allocation_guard: &guard,
6288                lock_wait: Duration::ZERO,
6289            })
6290            .await
6291            .expect_err("prepare failed after creating resource");
6292        assert_eq!(
6293            failure.reason,
6294            FailureReason::IsolationProvider(IsolationProviderFailure::RuntimeOperationFailed)
6295        );
6296        assert_eq!(provider.resource_count(), 1);
6297        assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
6298        assert!(
6299            !harness
6300                .store
6301                .dump_text()
6302                .unwrap()
6303                .contains("provider-secret-needle")
6304        );
6305        let id = harness.store.attempts().unwrap()[0].id;
6306        assert_eq!(
6307            harness.store.attempt(id).unwrap().unwrap().outcome(),
6308            Some(&AttemptOutcome::failed(FailureReason::IsolationProvider(
6309                IsolationProviderFailure::RuntimeOperationFailed
6310            )))
6311        );
6312        let restarted = launcher_with_isolated(&harness, Arc::clone(&provider));
6313        restarted
6314            .recover_startup(std::slice::from_ref(&harness.policy))
6315            .await
6316            .expect("profile-local recovery");
6317        assert_eq!(
6318            provider.resource_count(),
6319            0,
6320            "the exact owned resource was destroyed once"
6321        );
6322        assert_eq!(
6323            harness.store.attempt(id).unwrap().unwrap().state(),
6324            AttemptState::Cleaned
6325        );
6326        let diagnostic =
6327            fs::read_to_string(restarted.diagnostics_root.join(format!("{id}.log"))).unwrap();
6328        assert!(diagnostic.contains("reason=isolation_runtime_operation_failed"));
6329        assert!(!diagnostic.contains("provider-secret-needle"));
6330        assert_eq!(
6331            provider
6332                .actions
6333                .lock()
6334                .unwrap()
6335                .iter()
6336                .filter(|action| **action == "destroy")
6337                .count(),
6338            1
6339        );
6340        assert_eq!(provider.native_calls.load(Ordering::SeqCst), 0);
6341    }
6342
6343    #[test]
6344    fn isolation_capability_and_adapter_failures_keep_closed_categories() {
6345        let cases = [
6346            (
6347                ProviderCapability::NotInstalled,
6348                IsolationProviderFailure::NotInstalled,
6349            ),
6350            (
6351                ProviderCapability::PermissionDenied,
6352                IsolationProviderFailure::PermissionDenied,
6353            ),
6354            (
6355                ProviderCapability::ImageUnavailableOrIncompatible,
6356                IsolationProviderFailure::ImageUnavailableOrIncompatible,
6357            ),
6358            (
6359                ProviderCapability::DiskQuotaUnavailable,
6360                IsolationProviderFailure::DiskQuotaUnavailable,
6361            ),
6362            (
6363                ProviderCapability::Degraded,
6364                IsolationProviderFailure::Degraded,
6365            ),
6366        ];
6367        for (capability, category) in cases {
6368            assert_eq!(
6369                capability.refusal(),
6370                FailureReason::IsolationProvider(category)
6371            );
6372        }
6373        let raw = FailureReason::Other("ghp_secret_from_provider".into());
6374        let safe = safe_isolation_reason(raw, IsolationProviderFailure::RuntimeOperationFailed);
6375        assert_eq!(
6376            safe,
6377            FailureReason::IsolationProvider(IsolationProviderFailure::RuntimeOperationFailed)
6378        );
6379        assert!(!safe.to_string().contains("ghp_secret"));
6380        assert_eq!(
6381            safe_isolation_reason(
6382                FailureReason::IsolationProvider(IsolationProviderFailure::PermissionDenied),
6383                IsolationProviderFailure::RuntimeOperationFailed,
6384            ),
6385            FailureReason::IsolationProvider(IsolationProviderFailure::PermissionDenied)
6386        );
6387    }
6388
6389    #[tokio::test]
6390    async fn isolated_permission_and_image_failures_stop_before_jit_without_native_fallback() {
6391        for image_failure in [false, true] {
6392            let harness = isolated_harness();
6393            let provider = Arc::new(FakeIsolatedProvider::default());
6394            provider
6395                .permission_denied
6396                .store(!image_failure, Ordering::SeqCst);
6397            provider
6398                .incompatible_image
6399                .store(image_failure, Ordering::SeqCst);
6400            let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
6401            launcher
6402                .recover_startup(std::slice::from_ref(&harness.policy))
6403                .await
6404                .unwrap();
6405            let guard = harness.allocation_lock.acquire().await.unwrap();
6406            let error = launcher
6407                .launch(LaunchRequest {
6408                    host: &harness.host,
6409                    policy: &harness.policy,
6410                    allocation_guard: &guard,
6411                    lock_wait: Duration::ZERO,
6412                })
6413                .await
6414                .expect_err("pre-JIT refusal");
6415            let expected = if image_failure {
6416                "resolved image is incompatible"
6417            } else {
6418                "permission denied"
6419            };
6420            assert!(error.reason.to_string().contains(expected));
6421            assert!(
6422                harness.store.attempts().unwrap().is_empty(),
6423                "no allocation was journalled"
6424            );
6425            assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
6426            assert_eq!(provider.handoffs.load(Ordering::SeqCst), 0);
6427            assert_eq!(provider.native_calls.load(Ordering::SeqCst), 0);
6428        }
6429    }
6430
6431    #[tokio::test]
6432    async fn unsupported_host_stops_before_jit_without_allocation_or_native_fallback() {
6433        let harness = isolated_harness();
6434        let provider = Arc::new(FakeIsolatedProvider::default());
6435        provider.unsupported_host.store(true, Ordering::SeqCst);
6436        let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
6437        launcher
6438            .recover_startup(std::slice::from_ref(&harness.policy))
6439            .await
6440            .unwrap();
6441        let guard = harness.allocation_lock.acquire().await.unwrap();
6442        let error = launcher
6443            .launch(LaunchRequest {
6444                host: &harness.host,
6445                policy: &harness.policy,
6446                allocation_guard: &guard,
6447                lock_wait: Duration::ZERO,
6448            })
6449            .await
6450            .expect_err("unsupported host must be refused before JIT");
6451
6452        assert_eq!(
6453            error.reason,
6454            FailureReason::IsolationProvider(IsolationProviderFailure::Unsupported)
6455        );
6456        assert!(harness.store.attempts().unwrap().is_empty());
6457        assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
6458        assert_eq!(provider.handoffs.load(Ordering::SeqCst), 0);
6459        assert_eq!(provider.native_calls.load(Ordering::SeqCst), 0);
6460        assert!(provider.actions.lock().unwrap().is_empty());
6461    }
6462
6463    #[tokio::test]
6464    async fn no_journalled_environment_id_waits_for_provider_absence_proof() {
6465        let harness = isolated_harness();
6466        let provider = Arc::new(FakeIsolatedProvider::default());
6467        let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
6468        let runtime = harness.host_root().join("prepare-without-identity");
6469        fs::create_dir_all(&runtime).unwrap();
6470        let mut attempt = RunnerAttempt::allocate(
6471            AttemptId::from_u128(0x9191),
6472            harness.policy.id,
6473            runtime,
6474            harness.clock.now(),
6475        );
6476        let runner_manager_domain::execution::ExecutionPolicy::Isolated { image, .. } =
6477            harness.policy.execution_policy()
6478        else {
6479            unreachable!()
6480        };
6481        attempt
6482            .allocate_execution(AttemptExecution::Isolated {
6483                provider_kind: Backend::Oci,
6484                environment_id: None,
6485                resolved_image: image.clone(),
6486                generation: "prepare-generation".into(),
6487            })
6488            .unwrap();
6489        attempt.begin_prepare(harness.clock.now()).unwrap();
6490        attempt
6491            .conclude(
6492                AttemptOutcome::failed(FailureReason::Other("prepare interrupted".into())),
6493                harness.clock.now(),
6494            )
6495            .unwrap();
6496        harness.store.record_attempt(&attempt).unwrap();
6497
6498        provider.recovery_unavailable.store(true, Ordering::SeqCst);
6499        launcher
6500            .clean(attempt.id)
6501            .await
6502            .expect_err("absence unproven");
6503        let deferred = harness.store.attempt(attempt.id).unwrap().unwrap();
6504        assert_eq!(deferred.state(), AttemptState::CleanupDeferred);
6505        assert!(deferred.counts_against_capacity());
6506
6507        provider.recovery_unavailable.store(false, Ordering::SeqCst);
6508        launcher.clean(attempt.id).await.expect("absence proven");
6509        assert_eq!(
6510            harness.store.attempt(attempt.id).unwrap().unwrap().state(),
6511            AttemptState::Cleaned
6512        );
6513    }
6514
6515    #[tokio::test]
6516    async fn isolated_terminal_capacity_waits_for_confirmed_absence_and_generation_matches() {
6517        let harness = isolated_harness();
6518        let provider = Arc::new(FakeIsolatedProvider::default());
6519        let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
6520        launcher
6521            .recover_startup(std::slice::from_ref(&harness.policy))
6522            .await
6523            .expect("ready");
6524        let guard = harness.allocation_lock.acquire().await.unwrap();
6525        let mut attempt = launcher
6526            .launch(LaunchRequest {
6527                host: &harness.host,
6528                policy: &harness.policy,
6529                allocation_guard: &guard,
6530                lock_wait: Duration::ZERO,
6531            })
6532            .await
6533            .expect("launch");
6534        attempt
6535            .conclude(
6536                AttemptOutcome::failed(FailureReason::ProcessStartFailed),
6537                harness.clock.now(),
6538            )
6539            .unwrap();
6540        harness.store.record_attempt(&attempt).unwrap();
6541        provider.destroy_deferred.store(true, Ordering::SeqCst);
6542        launcher
6543            .clean(attempt.id)
6544            .await
6545            .expect_err("provider deferred destroy");
6546        let deferred = harness.store.attempt(attempt.id).unwrap().unwrap();
6547        assert_eq!(deferred.state(), AttemptState::CleanupDeferred);
6548        assert!(deferred.counts_against_capacity());
6549        assert_eq!(provider.resource_count(), 1);
6550        provider.destroy_deferred.store(false, Ordering::SeqCst);
6551        let restarted = launcher_with_isolated(&harness, Arc::clone(&provider));
6552        restarted
6553            .recover_startup(std::slice::from_ref(&harness.policy))
6554            .await
6555            .expect("retry cleanup");
6556        assert_eq!(provider.resource_count(), 0);
6557        assert!(
6558            !harness
6559                .store
6560                .attempt(attempt.id)
6561                .unwrap()
6562                .unwrap()
6563                .counts_against_capacity()
6564        );
6565
6566        // A reused runtime name with a different generation is not ours to delete.
6567        let second_id = AttemptId::from_u128(0x9999);
6568        let second_runtime = harness.host_root().join("generation-mismatch");
6569        fs::create_dir_all(&second_runtime).unwrap();
6570        let mut terminal = RunnerAttempt::allocate(
6571            second_id,
6572            harness.policy.id,
6573            second_runtime,
6574            harness.clock.now(),
6575        );
6576        let runner_manager_domain::execution::ExecutionPolicy::Isolated { image, .. } =
6577            harness.policy.execution_policy()
6578        else {
6579            unreachable!()
6580        };
6581        terminal
6582            .allocate_execution(AttemptExecution::Isolated {
6583                provider_kind: Backend::Oci,
6584                environment_id: None,
6585                resolved_image: image.clone(),
6586                generation: "expected-generation".into(),
6587            })
6588            .unwrap();
6589        let mut stranger = FakeIsolatedProvider::identity(&terminal, harness.policy.host_id);
6590        let EnvironmentIdentity::Isolated { environment_id, .. } = &stranger else {
6591            unreachable!()
6592        };
6593        terminal.begin_prepare(harness.clock.now()).unwrap();
6594        terminal
6595            .prepared_environment(environment_id.clone())
6596            .unwrap();
6597        terminal.mark_prepared(harness.clock.now()).unwrap();
6598        terminal.jit_received(harness.clock.now()).unwrap();
6599        terminal.started_isolated(harness.clock.now()).unwrap();
6600        terminal
6601            .conclude(
6602                AttemptOutcome::failed(FailureReason::ProcessStartFailed),
6603                harness.clock.now(),
6604            )
6605            .unwrap();
6606        harness.store.record_attempt(&terminal).unwrap();
6607        let EnvironmentIdentity::Isolated { generation, .. } = &mut stranger else {
6608            unreachable!()
6609        };
6610        *generation = "another-generation".into();
6611        provider
6612            .resources
6613            .lock()
6614            .unwrap()
6615            .insert(second_id, (stranger, EnvironmentState::Running));
6616        restarted
6617            .clean(terminal.id)
6618            .await
6619            .expect_err("ownership mismatch quarantined");
6620        assert_eq!(provider.resource_count(), 1);
6621        assert_eq!(
6622            harness.store.attempt(terminal.id).unwrap().unwrap().state(),
6623            AttemptState::CleanupDeferred
6624        );
6625        assert_eq!(provider.native_calls.load(Ordering::SeqCst), 0);
6626    }
6627
6628    #[tokio::test]
6629    async fn isolated_restart_at_each_transition_adopts_or_destroys_one_resource() {
6630        for stage in [
6631            AttemptState::Allocated,
6632            AttemptState::Preparing,
6633            AttemptState::Prepared,
6634            AttemptState::JitReceived,
6635            AttemptState::Starting,
6636            AttemptState::Idle,
6637            AttemptState::Busy,
6638            AttemptState::Finished,
6639            AttemptState::Failed,
6640            AttemptState::Orphaned,
6641            AttemptState::Destroying,
6642            AttemptState::CleanupDeferred,
6643        ] {
6644            let harness = isolated_harness();
6645            let provider = Arc::new(FakeIsolatedProvider::default());
6646            let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
6647            let id = AttemptId::from_u128(0x1000 + stage as u128);
6648            let runtime = harness.host_root().join(format!("phase-{stage}"));
6649            fs::create_dir_all(&runtime).unwrap();
6650            let mut attempt =
6651                RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
6652            let runner_manager_domain::execution::ExecutionPolicy::Isolated { image, .. } =
6653                harness.policy.execution_policy()
6654            else {
6655                unreachable!()
6656            };
6657            attempt
6658                .allocate_execution(AttemptExecution::Isolated {
6659                    provider_kind: Backend::Oci,
6660                    environment_id: None,
6661                    resolved_image: image.clone(),
6662                    generation: format!("generation-{stage}"),
6663                })
6664                .unwrap();
6665            if stage != AttemptState::Allocated {
6666                attempt.begin_prepare(harness.clock.now()).unwrap();
6667            }
6668            let identity = FakeIsolatedProvider::identity(&attempt, harness.policy.host_id);
6669            if stage != AttemptState::Allocated {
6670                let resource_state = if matches!(
6671                    stage,
6672                    AttemptState::Finished
6673                        | AttemptState::Failed
6674                        | AttemptState::Orphaned
6675                        | AttemptState::Destroying
6676                        | AttemptState::CleanupDeferred
6677                ) {
6678                    EnvironmentState::Exited
6679                } else {
6680                    EnvironmentState::Running
6681                };
6682                provider
6683                    .resources
6684                    .lock()
6685                    .unwrap()
6686                    .insert(id, (identity.clone(), resource_state));
6687            }
6688            if !matches!(stage, AttemptState::Allocated | AttemptState::Preparing) {
6689                let EnvironmentIdentity::Isolated { environment_id, .. } = &identity else {
6690                    unreachable!()
6691                };
6692                attempt
6693                    .prepared_environment(environment_id.clone())
6694                    .unwrap();
6695                attempt.mark_prepared(harness.clock.now()).unwrap();
6696            }
6697            if matches!(
6698                stage,
6699                AttemptState::JitReceived
6700                    | AttemptState::Starting
6701                    | AttemptState::Idle
6702                    | AttemptState::Busy
6703                    | AttemptState::Finished
6704                    | AttemptState::Failed
6705                    | AttemptState::Orphaned
6706                    | AttemptState::Destroying
6707                    | AttemptState::CleanupDeferred
6708            ) {
6709                attempt.jit_received(harness.clock.now()).unwrap();
6710            }
6711            if matches!(
6712                stage,
6713                AttemptState::Starting
6714                    | AttemptState::Idle
6715                    | AttemptState::Busy
6716                    | AttemptState::Finished
6717                    | AttemptState::Failed
6718                    | AttemptState::Orphaned
6719                    | AttemptState::Destroying
6720                    | AttemptState::CleanupDeferred
6721            ) {
6722                attempt.started_isolated(harness.clock.now()).unwrap();
6723            }
6724            if matches!(
6725                stage,
6726                AttemptState::Idle | AttemptState::Busy | AttemptState::Finished
6727            ) {
6728                attempt.registered_idle(73, harness.clock.now()).unwrap();
6729            }
6730            if matches!(stage, AttemptState::Busy | AttemptState::Finished) {
6731                attempt.assigned_job(73, harness.clock.now()).unwrap();
6732            }
6733            if matches!(
6734                stage,
6735                AttemptState::Finished
6736                    | AttemptState::Failed
6737                    | AttemptState::Orphaned
6738                    | AttemptState::Destroying
6739                    | AttemptState::CleanupDeferred
6740            ) {
6741                let outcome = match stage {
6742                    AttemptState::Finished => AttemptOutcome::CompletedJob,
6743                    AttemptState::Orphaned => AttemptOutcome::Orphaned,
6744                    _ => AttemptOutcome::failed(FailureReason::ProcessStartFailed),
6745                };
6746                attempt.conclude(outcome, harness.clock.now()).unwrap();
6747            }
6748            if matches!(
6749                stage,
6750                AttemptState::Destroying | AttemptState::CleanupDeferred
6751            ) {
6752                attempt.begin_destroy(harness.clock.now()).unwrap();
6753            }
6754            if stage == AttemptState::CleanupDeferred {
6755                attempt.defer_cleanup(harness.clock.now()).unwrap();
6756            }
6757            harness.store.record_attempt(&attempt).unwrap();
6758            launcher
6759                .recover_startup(std::slice::from_ref(&harness.policy))
6760                .await
6761                .unwrap_or_else(|error| panic!("restart from {stage}: {error}"));
6762            let resumed = harness.store.attempt(id).unwrap().unwrap();
6763            if matches!(
6764                stage,
6765                AttemptState::Allocated
6766                    | AttemptState::Preparing
6767                    | AttemptState::Prepared
6768                    | AttemptState::Finished
6769                    | AttemptState::Failed
6770                    | AttemptState::Orphaned
6771                    | AttemptState::Destroying
6772                    | AttemptState::CleanupDeferred
6773            ) {
6774                assert_eq!(
6775                    resumed.state(),
6776                    AttemptState::Cleaned,
6777                    "{stage} must retire after restart"
6778                );
6779            } else {
6780                assert!(resumed.counts_against_capacity(), "live {stage} is adopted");
6781                assert_eq!(provider.resource_count(), 1);
6782                provider.resources.lock().unwrap().get_mut(&id).unwrap().1 =
6783                    EnvironmentState::Exited;
6784                launcher.supervise(&harness.policy).await.unwrap();
6785                assert_eq!(
6786                    harness.store.attempt(id).unwrap().unwrap().state(),
6787                    AttemptState::Cleaned
6788                );
6789            }
6790            assert_eq!(
6791                provider.resource_count(),
6792                0,
6793                "{stage} resource cleaned exactly once"
6794            );
6795            let destroyed = provider
6796                .actions
6797                .lock()
6798                .unwrap()
6799                .iter()
6800                .filter(|action| **action == "destroy")
6801                .count();
6802            assert_eq!(
6803                destroyed,
6804                usize::from(stage != AttemptState::Allocated),
6805                "{stage} destroy count"
6806            );
6807            assert_eq!(
6808                harness.github.registrations.load(Ordering::SeqCst),
6809                0,
6810                "restart from {stage} cannot duplicate JIT"
6811            );
6812            assert_eq!(provider.native_calls.load(Ordering::SeqCst), 0);
6813        }
6814    }
6815
6816    #[tokio::test]
6817    async fn duplicate_provider_ownership_is_quarantined_until_unambiguous() {
6818        let harness = isolated_harness();
6819        let provider = Arc::new(FakeIsolatedProvider::default());
6820        let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
6821        let id = AttemptId::from_u128(0x7777);
6822        let runtime = harness.host_root().join("duplicate-provider-ownership");
6823        fs::create_dir_all(&runtime).unwrap();
6824        let mut attempt =
6825            RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
6826        let runner_manager_domain::execution::ExecutionPolicy::Isolated { image, .. } =
6827            harness.policy.execution_policy()
6828        else {
6829            unreachable!()
6830        };
6831        attempt
6832            .allocate_execution(AttemptExecution::Isolated {
6833                provider_kind: Backend::Oci,
6834                environment_id: None,
6835                resolved_image: image.clone(),
6836                generation: "duplicate-generation".into(),
6837            })
6838            .unwrap();
6839        attempt.begin_prepare(harness.clock.now()).unwrap();
6840        harness.store.record_attempt(&attempt).unwrap();
6841        let first = FakeIsolatedProvider::identity(&attempt, harness.policy.host_id);
6842        let mut second = first.clone();
6843        let EnvironmentIdentity::Isolated { environment_id, .. } = &mut second else {
6844            unreachable!()
6845        };
6846        *environment_id = "another-environment".into();
6847        provider
6848            .resources
6849            .lock()
6850            .unwrap()
6851            .insert(id, (first, EnvironmentState::Running));
6852        provider.extra_resources.lock().unwrap().push(second);
6853        launcher
6854            .recover_startup(std::slice::from_ref(&harness.policy))
6855            .await
6856            .expect("duplicate is local");
6857        let deferred = harness.store.attempt(id).unwrap().unwrap();
6858        assert_eq!(deferred.state(), AttemptState::CleanupDeferred);
6859        assert!(deferred.counts_against_capacity());
6860        assert_eq!(provider.resource_count(), 1);
6861        assert_eq!(
6862            provider
6863                .actions
6864                .lock()
6865                .unwrap()
6866                .iter()
6867                .filter(|action| **action == "destroy")
6868                .count(),
6869            0
6870        );
6871        provider.extra_resources.lock().unwrap().clear();
6872        // A tolerated cleanup failure is retried after its backoff delay.
6873        harness.clock.advance_secs(31);
6874        launcher
6875            .recover_startup(std::slice::from_ref(&harness.policy))
6876            .await
6877            .expect("single identity recovered");
6878        assert_eq!(provider.resource_count(), 0);
6879        assert_eq!(
6880            harness.store.attempt(id).unwrap().unwrap().state(),
6881            AttemptState::Cleaned
6882        );
6883    }
6884
6885    #[tokio::test]
6886    async fn provider_resource_without_live_journal_is_surfaced_but_never_deleted() {
6887        let harness = isolated_harness();
6888        let provider = Arc::new(FakeIsolatedProvider::default());
6889        let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
6890        let id = AttemptId::from_u128(0x8888);
6891        let runtime = harness.host_root().join("orphan-provider-resource");
6892        fs::create_dir_all(&runtime).unwrap();
6893        let mut attempt =
6894            RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
6895        let runner_manager_domain::execution::ExecutionPolicy::Isolated { image, .. } =
6896            harness.policy.execution_policy()
6897        else {
6898            unreachable!()
6899        };
6900        attempt
6901            .allocate_execution(AttemptExecution::Isolated {
6902                provider_kind: Backend::Oci,
6903                environment_id: None,
6904                resolved_image: image.clone(),
6905                generation: "orphan-generation".into(),
6906            })
6907            .unwrap();
6908        let identity = FakeIsolatedProvider::identity(&attempt, harness.policy.host_id);
6909        provider
6910            .resources
6911            .lock()
6912            .unwrap()
6913            .insert(id, (identity, EnvironmentState::Running));
6914        launcher
6915            .recover_startup(std::slice::from_ref(&harness.policy))
6916            .await
6917            .expect("orphan cannot block host");
6918        assert_eq!(provider.resource_count(), 1);
6919        assert!(
6920            harness
6921                .events
6922                .events()
6923                .contains(&AttemptEvent::QuarantinedOwnedResource { attempt: id })
6924        );
6925        assert_eq!(
6926            provider
6927                .actions
6928                .lock()
6929                .unwrap()
6930                .iter()
6931                .filter(|action| **action == "destroy")
6932                .count(),
6933            0
6934        );
6935
6936        let EnvironmentIdentity::Isolated { environment_id, .. } = provider
6937            .resources
6938            .lock()
6939            .unwrap()
6940            .get(&id)
6941            .unwrap()
6942            .0
6943            .clone()
6944        else {
6945            unreachable!()
6946        };
6947        attempt.begin_prepare(harness.clock.now()).unwrap();
6948        attempt.prepared_environment(environment_id).unwrap();
6949        attempt.mark_prepared(harness.clock.now()).unwrap();
6950        attempt.jit_received(harness.clock.now()).unwrap();
6951        attempt.started_isolated(harness.clock.now()).unwrap();
6952        attempt
6953            .conclude(
6954                AttemptOutcome::failed(FailureReason::ProcessStartFailed),
6955                harness.clock.now(),
6956            )
6957            .unwrap();
6958        attempt.begin_destroy(harness.clock.now()).unwrap();
6959        attempt.clean_isolated(harness.clock.now()).unwrap();
6960        harness.store.record_attempt(&attempt).unwrap();
6961        launcher
6962            .recover_startup(std::slice::from_ref(&harness.policy))
6963            .await
6964            .expect("cleaned row cannot authorize deletion");
6965        assert_eq!(provider.resource_count(), 1);
6966        assert_eq!(
6967            provider
6968                .actions
6969                .lock()
6970                .unwrap()
6971                .iter()
6972                .filter(|action| **action == "destroy")
6973                .count(),
6974            0
6975        );
6976    }
6977
6978    /// The wiring the three-hour outage needed and did not have.
6979    ///
6980    /// A root that refuses a launch does so before `record_allocation`, so no
6981    /// attempt row carries it, and the daemon's log scrubs the paths out of the
6982    /// sentence. The record this asserts is the only surface left, and
6983    /// `service status` reads it -- so if this wiring is ever dropped, the
6984    /// failure goes back to reading `runner_start_failed reason=other` once per
6985    /// poll and nothing else.
6986    #[tokio::test]
6987    async fn a_root_that_refuses_a_launch_is_recorded_and_cleared_when_one_succeeds() {
6988        use runner_manager_platform::service::{clear_runner_root_refusal, runner_root_refusals};
6989
6990        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
6991            .with_host_runner_root();
6992        harness.ready().await;
6993
6994        // A root under two directories that do not exist: refused by the
6995        // preflight, for a reason that has nothing to do with this platform, so
6996        // the assertion holds on all three.
6997        let unusable = harness
6998            ._root
6999            .path()
7000            .join("absent")
7001            .join("deeper")
7002            .join("runners");
7003        let mut host = harness.host.clone();
7004        host.runner_root_override = Some(
7005            LocalAbsolutePath::new(unusable.to_str().expect("a UTF-8 temporary path"))
7006                .expect("a local absolute host root"),
7007        );
7008        harness.store.put_host(&host).unwrap();
7009
7010        let failure = harness
7011            .launch_result()
7012            .await
7013            .expect_err("a root whose parents are missing cannot hold a runner");
7014        assert!(
7015            matches!(failure.reason, FailureReason::Other(_)),
7016            "{failure:?}"
7017        );
7018
7019        let refusals = runner_root_refusals(&harness.app_paths).expect("readable");
7020        let refusal = refusals
7021            .first()
7022            .expect("the refusal reached the one surface that can hold it");
7023        assert_eq!(refusal.policy, harness.policy.id.to_string());
7024        assert_eq!(refusal.kind, "missing_parents");
7025        assert!(
7026            refusal.root.contains("runners") && refusal.detail.contains("runners"),
7027            "the directory must be named in full: {refusal:?}"
7028        );
7029
7030        // And a root that works clears it, so a host that has been fixed stops
7031        // reporting a fault it no longer has.
7032        harness.store.put_host(&harness.host).unwrap();
7033        harness.launch().await;
7034        assert!(
7035            runner_root_refusals(&harness.app_paths)
7036                .expect("readable")
7037                .is_empty(),
7038            "a successful placement clears that policy's record"
7039        );
7040
7041        clear_runner_root_refusal(&harness.app_paths, &harness.policy.id.to_string())
7042            .expect("cleanup");
7043    }
7044
7045    #[tokio::test]
7046    async fn a_launch_reports_how_long_each_step_took() {
7047        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7048        harness.ready().await;
7049        let started = harness.launch().await;
7050        let events = harness.events.events();
7051        let launched = events
7052            .iter()
7053            .position(|event| matches!(event, AttemptEvent::Launched { attempt, .. } if *attempt == started.id))
7054            .expect("a successful launch reports its step timings");
7055        let starting = events
7056            .iter()
7057            .position(|event| matches!(event, AttemptEvent::State { attempt, state: AttemptState::Starting } if *attempt == started.id))
7058            .unwrap();
7059        assert!(starting < launched, "timings follow the start: {events:?}");
7060    }
7061
7062    #[tokio::test]
7063    async fn a_failed_launch_still_reports_its_step_timings() {
7064        let harness = Harness::new(
7065            FakeGithubLifecycle::default().fail(true),
7066            Arc::new(PersistentDemand),
7067        );
7068        harness.ready().await;
7069        let failed = harness.launch_after_waiting(Duration::from_secs(90)).await;
7070        assert!(failed.is_err());
7071
7072        let timings = harness
7073            .events
7074            .events()
7075            .into_iter()
7076            .find_map(|event| match event {
7077                AttemptEvent::Launched {
7078                    started: false,
7079                    timings,
7080                    ..
7081                } => Some(timings),
7082                _ => None,
7083            })
7084            .expect("a launch that failed at registration reports its timings");
7085        assert_eq!(timings.lock_wait, Duration::from_secs(90));
7086        assert_eq!(timings.spawn, Duration::ZERO, "spawn was never reached");
7087        assert!(timings.total() >= Duration::from_secs(90));
7088    }
7089
7090    #[cfg(target_os = "macos")]
7091    #[tokio::test]
7092    async fn a_launch_removes_the_seed_of_a_root_no_longer_in_use() {
7093        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7094        harness.ready().await;
7095
7096        // A runtime this host once placed under a root it no longer uses.
7097        let old_root = harness._root.path().join("old-root");
7098        let old_runtime = old_root.join("abandoned");
7099        fs::create_dir_all(old_root.join(PACKAGE_SEED_DIR).join("2.335.0")).unwrap();
7100        fs::create_dir_all(&old_runtime).unwrap();
7101        let old = RunnerAttempt::allocate(
7102            AttemptId::new_random(),
7103            harness.policy.id,
7104            &old_runtime,
7105            harness.clock.now(),
7106        );
7107        harness.store.record_attempt(&old).unwrap();
7108        let kept = harness.host_root().join(PACKAGE_SEED_DIR);
7109        fs::create_dir_all(kept.join("2.336.0")).unwrap();
7110
7111        harness.launch().await;
7112
7113        assert!(
7114            !old_root.join(PACKAGE_SEED_DIR).exists(),
7115            "the abandoned root's seed is removed"
7116        );
7117        assert!(old_runtime.exists(), "only the seed is touched");
7118        assert!(kept.exists(), "the host root's own seed is kept");
7119    }
7120
7121    #[tokio::test]
7122    async fn a_job_walks_every_state_and_cleans_every_artifact() {
7123        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7124        harness.ready().await;
7125        let started = harness.launch().await;
7126        assert_eq!(started.state(), AttemptState::Starting);
7127        assert_eq!(read_runner_id(started.runtime_path()), Some(73));
7128
7129        harness
7130            .github
7131            .observe(GithubRunnerObservation::Registered { busy: false });
7132        harness.launcher.supervise(&harness.policy).await.unwrap();
7133        assert_eq!(harness.only_attempt().state(), AttemptState::Idle);
7134
7135        harness
7136            .github
7137            .observe(GithubRunnerObservation::Registered { busy: true });
7138        harness.launcher.supervise(&harness.policy).await.unwrap();
7139        assert_eq!(harness.only_attempt().state(), AttemptState::Busy);
7140
7141        harness.processes.finish_successfully();
7142        harness
7143            .github
7144            .observe(GithubRunnerObservation::NotRegistered);
7145        harness.launcher.supervise(&harness.policy).await.unwrap();
7146        let cleaned = harness.only_attempt();
7147        assert_eq!(cleaned.state(), AttemptState::Cleaned);
7148        assert_eq!(cleaned.outcome(), Some(&AttemptOutcome::CompletedJob));
7149        assert!(!started.runtime_path().exists());
7150        assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 1);
7151        assert_eq!(harness.github.remaining_runners.load(Ordering::SeqCst), 0);
7152
7153        let states: Vec<_> = harness
7154            .events
7155            .events()
7156            .into_iter()
7157            .filter_map(|event| match event {
7158                AttemptEvent::State { state, .. } => Some(state),
7159                _ => None,
7160            })
7161            .collect();
7162        assert_eq!(
7163            states,
7164            vec![
7165                AttemptState::Allocated,
7166                AttemptState::JitReceived,
7167                AttemptState::Starting,
7168                AttemptState::Idle,
7169                AttemptState::Busy,
7170                AttemptState::Finished,
7171                AttemptState::Cleaned,
7172            ]
7173        );
7174    }
7175
7176    #[tokio::test]
7177    async fn a_cleaned_ephemeral_attempt_reaps_a_directory_recreated_after_cleanup() {
7178        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7179        harness.ready().await;
7180        let started = harness.launch().await;
7181        let runtime = started.runtime_path().to_path_buf();
7182
7183        harness
7184            .github
7185            .observe(GithubRunnerObservation::Registered { busy: true });
7186        harness.launcher.supervise(&harness.policy).await.unwrap();
7187        harness.processes.finish_successfully();
7188        harness
7189            .github
7190            .observe(GithubRunnerObservation::NotRegistered);
7191        harness.launcher.supervise(&harness.policy).await.unwrap();
7192
7193        assert_eq!(harness.attempt(started.id).state(), AttemptState::Cleaned);
7194        assert!(!runtime.exists());
7195        assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 1);
7196
7197        // Reproduce a late orphan creating output after the successful removal
7198        // and journal transition. The next ordinary policy supervision must
7199        // remove it without replaying package release or another state change.
7200        let residue = runtime.join("_work").join("late-node-process");
7201        fs::create_dir_all(&residue).unwrap();
7202        fs::write(residue.join("node_modules.lock"), b"late residue").unwrap();
7203
7204        harness.launcher.supervise(&harness.policy).await.unwrap();
7205
7206        assert!(!runtime.exists(), "late ephemeral residue is reaped");
7207        assert_eq!(harness.attempt(started.id).state(), AttemptState::Cleaned);
7208        assert_eq!(
7209            harness.packages.releases.load(Ordering::SeqCst),
7210            1,
7211            "reaping residue does not release the package lease twice"
7212        );
7213    }
7214
7215    #[tokio::test]
7216    async fn locked_late_residue_never_blocks_startup_recovery() {
7217        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7218        harness.ready().await;
7219        let started = harness.launch().await;
7220        let runtime = started.runtime_path().to_path_buf();
7221
7222        harness
7223            .github
7224            .observe(GithubRunnerObservation::Registered { busy: true });
7225        harness.launcher.supervise(&harness.policy).await.unwrap();
7226        harness.processes.finish_successfully();
7227        harness
7228            .github
7229            .observe(GithubRunnerObservation::NotRegistered);
7230        harness.launcher.supervise(&harness.policy).await.unwrap();
7231        assert_eq!(harness.attempt(started.id).state(), AttemptState::Cleaned);
7232
7233        let held = runtime.join("_work").join("late-node-process");
7234        fs::create_dir_all(&held).unwrap();
7235        fs::write(held.join("node_modules.lock"), b"late residue").unwrap();
7236        let Some(block) = BlockedDeletion::inject(&held) else {
7237            eprintln!(
7238                "skipped: this account cannot be refused a deletion, so locked late residue cannot be injected"
7239            );
7240            return;
7241        };
7242
7243        let restarted = harness.restart();
7244        restarted
7245            .recover_startup(std::slice::from_ref(&harness.policy))
7246            .await
7247            .expect("late residue cannot take the daemon offline");
7248        assert!(runtime.exists(), "the locked residue remains for a retry");
7249        assert!(
7250            harness
7251                .reconcile_events
7252                .events()
7253                .iter()
7254                .any(|event| matches!(
7255                    event,
7256                    LifecycleEvent::AttemptCleanFailed {
7257                        attempt,
7258                        reason: "late_ephemeral_workspace_could_not_be_removed",
7259                        ..
7260                    } if *attempt == started.id
7261                )),
7262            "the non-blocking cleanup failure remains visible"
7263        );
7264
7265        block.release();
7266        // A tolerated cleanup failure is retried after its backoff delay.
7267        harness.clock.advance_secs(31);
7268        restarted
7269            .supervise(&harness.policy)
7270            .await
7271            .expect("the ordinary retry succeeds after the lock is released");
7272        assert!(!runtime.exists(), "the retry removes the late residue");
7273        assert_eq!(harness.attempt(started.id).state(), AttemptState::Cleaned);
7274        assert_eq!(
7275            harness.packages.releases.load(Ordering::SeqCst),
7276            1,
7277            "retrying residue never releases the package lease twice"
7278        );
7279    }
7280
7281    #[tokio::test]
7282    async fn mixed_locked_cleanup_residue_isolated_from_the_whole_startup_pass() {
7283        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7284        harness.ready().await;
7285
7286        // The first attempt is terminal but its initial cleanup cannot finish.
7287        // This is the state left by the original failed job on the affected
7288        // workstation.
7289        let terminal = harness.launch().await;
7290        let terminal_runtime = terminal.runtime_path().to_path_buf();
7291        harness.conclude(terminal.id);
7292        let Some(terminal_block) = BlockedDeletion::inject(&terminal_runtime.join("late-child"))
7293        else {
7294            eprintln!("skipped: this account cannot inject the two independent deletion refusals");
7295            return;
7296        };
7297
7298        // The second attempt was cleaned successfully, then a child recreated
7299        // part of `_work`. Both real-world residue shapes existed together in
7300        // the incident, and neither may short-circuit recovery of the other.
7301        let cleaned = harness.launch().await;
7302        let cleaned_runtime = cleaned.runtime_path().to_path_buf();
7303        harness.conclude(cleaned.id);
7304        harness
7305            .launcher
7306            .clean(cleaned.id)
7307            .await
7308            .expect("the second attempt initially cleans");
7309        let Some(cleaned_block) =
7310            BlockedDeletion::inject(&cleaned_runtime.join("_work").join("late-child"))
7311        else {
7312            terminal_block.release();
7313            eprintln!("skipped: this account cannot inject the two independent deletion refusals");
7314            return;
7315        };
7316
7317        let restarted = harness.restart();
7318        for _ in 0..2 {
7319            restarted
7320                .recover_startup(std::slice::from_ref(&harness.policy))
7321                .await
7322                .expect("repeated recovery remains ready while both residues are locked");
7323        }
7324        assert_ne!(
7325            harness.attempt(terminal.id).state(),
7326            AttemptState::Cleaned,
7327            "the journal does not claim the terminal workspace was removed"
7328        );
7329        assert_eq!(
7330            harness.attempt(cleaned.id).state(),
7331            AttemptState::Cleaned,
7332            "late residue does not undo an already durable cleaned transition"
7333        );
7334        assert!(terminal_runtime.exists());
7335        assert!(cleaned_runtime.exists());
7336        assert_eq!(
7337            harness.packages.releases.load(Ordering::SeqCst),
7338            1,
7339            "repeated recovery neither loses nor duplicates package leases"
7340        );
7341
7342        terminal_block.release();
7343        cleaned_block.release();
7344        // A tolerated cleanup failure is retried after its backoff delay.
7345        harness.clock.advance_secs(31);
7346        restarted
7347            .supervise(&harness.policy)
7348            .await
7349            .expect("one ordinary pass clears both residues after their locks disappear");
7350        assert!(!terminal_runtime.exists());
7351        assert!(!cleaned_runtime.exists());
7352        assert_eq!(harness.attempt(terminal.id).state(), AttemptState::Cleaned);
7353        assert_eq!(harness.attempt(cleaned.id).state(), AttemptState::Cleaned);
7354        assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 2);
7355    }
7356
7357    #[tokio::test]
7358    async fn a_terminal_attempt_whose_runtime_is_already_gone_recovers_cleanly() {
7359        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7360        harness.ready().await;
7361        let attempt = harness.launch().await;
7362        let runtime = attempt.runtime_path().to_path_buf();
7363        harness.conclude(attempt.id);
7364        fs::remove_dir_all(&runtime).unwrap();
7365
7366        harness
7367            .restart()
7368            .recover_startup(std::slice::from_ref(&harness.policy))
7369            .await
7370            .expect("an already absent disposable runtime is successful cleanup");
7371
7372        assert_eq!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
7373        assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 1);
7374    }
7375
7376    #[tokio::test]
7377    async fn a_non_workspace_cleanup_failure_still_fails_closed_and_can_retry() {
7378        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7379        harness.ready().await;
7380        let attempt = harness.launch().await;
7381        let runtime = attempt.runtime_path().to_path_buf();
7382        harness.conclude(attempt.id);
7383        harness.packages.fail_releases(1);
7384
7385        let restarted = harness.restart();
7386        let failure = restarted
7387            .recover_startup(std::slice::from_ref(&harness.policy))
7388            .await
7389            .expect_err("a package-accounting failure is not safe to downgrade to residue");
7390        assert!(
7391            failure
7392                .reason()
7393                .to_string()
7394                .contains("package lease could not be released")
7395        );
7396        assert!(
7397            !runtime.exists(),
7398            "workspace removal completed before release failed"
7399        );
7400        assert_ne!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
7401
7402        restarted
7403            .recover_startup(std::slice::from_ref(&harness.policy))
7404            .await
7405            .expect("the same journal entry retries safely after the transient failure");
7406        assert_eq!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
7407        assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 1);
7408    }
7409
7410    #[tokio::test]
7411    async fn startup_never_scrubs_a_cleaned_persistent_work_directory() {
7412        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
7413            .with_persistent_workspace(1);
7414        harness.ready().await;
7415        let attempt = harness.launch().await;
7416        let retained = attempt.runtime_path().join("_work").join("checkout-marker");
7417        fs::create_dir_all(retained.parent().unwrap()).unwrap();
7418        fs::write(&retained, b"persistent checkout").unwrap();
7419        harness.cleanup_retaining_work(attempt.id).await;
7420
7421        harness
7422            .restart()
7423            .recover_startup(std::slice::from_ref(&harness.policy))
7424            .await
7425            .expect("a cleaned persistent slot is already reconciled");
7426
7427        assert_eq!(fs::read(&retained).unwrap(), b"persistent checkout");
7428        assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 1);
7429    }
7430
7431    #[tokio::test]
7432    async fn an_idle_exit_is_not_a_failure_in_the_journal_or_events() {
7433        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7434        harness.ready().await;
7435        let started = harness.launch().await;
7436        harness
7437            .github
7438            .observe(GithubRunnerObservation::Registered { busy: false });
7439        harness.launcher.supervise(&harness.policy).await.unwrap();
7440        harness.clock.advance_secs(11);
7441        harness.processes.set_alive(false);
7442        harness
7443            .github
7444            .observe(GithubRunnerObservation::NotRegistered);
7445        harness.launcher.supervise(&harness.policy).await.unwrap();
7446
7447        let cleaned = harness.only_attempt();
7448        assert!(cleaned.outcome().unwrap().is_idle_exit());
7449        assert!(!cleaned.outcome().unwrap().is_failure());
7450        assert!(!started.runtime_path().exists());
7451        assert!(
7452            harness
7453                .reconcile_events
7454                .events()
7455                .iter()
7456                .any(|event| matches!(
7457                    event,
7458                    LifecycleEvent::AttemptCleaned {
7459                        outcome: OutcomeKind::IdleExit,
7460                        ..
7461                    }
7462                ))
7463        );
7464        assert!(!harness.events.events().iter().any(|event| matches!(
7465            event,
7466            AttemptEvent::Concluded {
7467                outcome: OutcomeKind::Failed,
7468                ..
7469            }
7470        )));
7471    }
7472
7473    #[tokio::test]
7474    async fn handoff_is_absent_after_success_and_every_failed_spawn_retry() {
7475        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7476        harness.processes.fail_spawns(2);
7477        harness.ready().await;
7478        let attempt = harness.launch().await;
7479        assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 3);
7480        assert!(harness.processes.saw_secret.load(Ordering::SeqCst));
7481        let names: Vec<_> = fs::read_dir(attempt.runtime_path())
7482            .unwrap()
7483            .map(|entry| entry.unwrap().file_name())
7484            .collect();
7485        assert!(
7486            names.iter().all(|name| {
7487                !name
7488                    .to_string_lossy()
7489                    .starts_with(RestrictiveHandoff::NAME_PREFIX)
7490            }),
7491            "JIT artifact survived: {names:?}"
7492        );
7493        assert_eq!(
7494            *harness.delay.0.lock().unwrap(),
7495            vec![Duration::from_millis(10), Duration::from_millis(20)]
7496        );
7497    }
7498
7499    #[tokio::test]
7500    async fn jit_retry_stops_with_demand_and_a_terminal_403_never_retries() {
7501        let gone = Harness::new(
7502            FakeGithubLifecycle::default().fail(false),
7503            Arc::new(FakeDemand::answering([false])),
7504        );
7505        gone.ready().await;
7506        assert!(gone.launch_result().await.is_err());
7507        assert_eq!(gone.github.registrations.load(Ordering::SeqCst), 1);
7508        assert!(gone.delay.0.lock().unwrap().is_empty());
7509
7510        let forbidden = Harness::new(
7511            FakeGithubLifecycle::default().fail(true),
7512            Arc::new(PersistentDemand),
7513        );
7514        forbidden.ready().await;
7515        assert!(forbidden.launch_result().await.is_err());
7516        assert_eq!(forbidden.github.registrations.load(Ordering::SeqCst), 1);
7517        assert!(forbidden.delay.0.lock().unwrap().is_empty());
7518        assert!(matches!(
7519            forbidden.only_attempt().outcome(),
7520            Some(AttemptOutcome::Failed {
7521                reason: FailureReason::Other(action)
7522            }) if action.contains("403")
7523        ));
7524
7525        let transient = Harness::new(
7526            FakeGithubLifecycle::default().fail(false).fail(false),
7527            Arc::new(PersistentDemand),
7528        );
7529        transient.ready().await;
7530        transient.launch().await;
7531        assert_eq!(transient.github.registrations.load(Ordering::SeqCst), 3);
7532        assert_eq!(
7533            *transient.delay.0.lock().unwrap(),
7534            vec![Duration::from_millis(10), Duration::from_millis(20)]
7535        );
7536    }
7537
7538    /// The layout has to leave room for what the runner writes underneath it.
7539    ///
7540    /// Windows refuses a path over `MAX_PATH`, and this product's own CI hit
7541    /// that: 264 characters against a limit of 260, failing three checkout
7542    /// retries with `Filename too long`. The two identifiers in the old layout
7543    /// cost 74 characters between them for no benefit -- an attempt id is
7544    /// unique on its own.
7545    #[test]
7546    fn a_workspace_leaves_room_for_the_deepest_path_a_checkout_writes() {
7547        const MAX_PATH: usize = 260;
7548        // The real root on the machine this was found on.
7549        let root = r"C:\Users\IvanD\AppData\Local\IvanMurzak\runner-manager\data\runtime";
7550        // What `actions/checkout` writes at its deepest: the work directory,
7551        // the repository named twice, and a pack keep-file with a 40-character
7552        // object name.
7553        let repo = "GitHub-Runner-Scaler-UI";
7554        let deepest = format!(
7555            r"_work\{repo}\{repo}\.git\objects\pack\pack-{}.keep",
7556            "0".repeat(40)
7557        );
7558
7559        let name = workspace_name(AttemptId::new_random());
7560        assert_eq!(name.len(), WORKSPACE_NAME_LEN, "{name}");
7561        assert!(
7562            name.chars().all(|c| c.is_ascii_hexdigit()),
7563            "a directory name must not carry the identifier's dashes: {name}"
7564        );
7565
7566        let full = format!(r"{root}\{name}\{deepest}");
7567        assert!(
7568            full.len() < MAX_PATH,
7569            "the deepest path a checkout writes must fit: {} characters, limit {MAX_PATH}",
7570            full.len()
7571        );
7572
7573        // The discriminator: the layout this replaced does not fit, so a test
7574        // that passed for both would be proving nothing.
7575        let old = format!(
7576            r"{root}\{}\{}\{deepest}",
7577            PolicyId::new_random(),
7578            AttemptId::new_random()
7579        );
7580        assert!(
7581            old.len() > MAX_PATH,
7582            "the old layout is supposed to be the thing that did not fit: {} characters",
7583            old.len()
7584        );
7585    }
7586
7587    #[tokio::test]
7588    async fn two_attempts_never_share_a_workspace_even_after_failure() {
7589        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7590        harness.ready().await;
7591        let first = harness.launch().await;
7592        fs::write(first.runtime_path().join("hostile-leftover"), b"first job").unwrap();
7593        harness
7594            .github
7595            .observe(GithubRunnerObservation::Registered { busy: false });
7596        harness.launcher.supervise(&harness.policy).await.unwrap();
7597        harness.clock.advance_secs(11);
7598        harness.processes.set_alive(false);
7599        harness
7600            .github
7601            .observe(GithubRunnerObservation::NotRegistered);
7602        harness.launcher.supervise(&harness.policy).await.unwrap();
7603        assert!(!first.runtime_path().exists());
7604
7605        let second = harness.launch().await;
7606        assert_ne!(first.runtime_path(), second.runtime_path());
7607        assert!(!second.runtime_path().join("hostile-leftover").exists());
7608
7609        fs::write(
7610            second.runtime_path().join("hostile-on-failure"),
7611            b"second job",
7612        )
7613        .unwrap();
7614        harness.processes.set_alive(false);
7615        harness
7616            .github
7617            .observe(GithubRunnerObservation::NotRegistered);
7618        harness.launcher.supervise(&harness.policy).await.unwrap();
7619        assert!(
7620            !second.runtime_path().exists(),
7621            "failed workspace was retained"
7622        );
7623    }
7624
7625    #[tokio::test]
7626    async fn a_runner_that_never_gets_a_job_is_stopped_deregistered_and_not_replaced() {
7627        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7628        harness.ready().await;
7629        let attempt = harness.launch().await;
7630
7631        // Registered and waiting, which is where it stays: the fake keeps
7632        // answering the same observation, exactly as GitHub does for a runner
7633        // nobody assigns work to.
7634        harness
7635            .github
7636            .observe(GithubRunnerObservation::Registered { busy: false });
7637        harness.launcher.supervise(&harness.policy).await.unwrap();
7638        assert_eq!(harness.only_attempt().state(), AttemptState::Idle);
7639
7640        // One second inside the ten-second idle timeout nothing happens, which
7641        // is what keeps this from being a test that would pass on any clock.
7642        harness.clock.advance_secs(9);
7643        harness
7644            .github
7645            .observe(GithubRunnerObservation::Registered { busy: false });
7646        let none_yet = harness.launcher.supervise(&harness.policy).await.unwrap();
7647        assert_eq!(harness.only_attempt().state(), AttemptState::Idle);
7648        assert!(none_yet.is_empty());
7649        assert_eq!(harness.processes.terminations.load(Ordering::SeqCst), 0);
7650
7651        // Past it, the agent ends the runner itself.
7652        harness.clock.advance_secs(1);
7653        harness
7654            .github
7655            .observe(GithubRunnerObservation::Registered { busy: false });
7656        let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
7657
7658        let concluded = harness.store.attempt(attempt.id).unwrap().unwrap();
7659        assert_eq!(
7660            concluded.outcome(),
7661            Some(&AttemptOutcome::ExitedIdleWithoutWork),
7662            "a surplus runner did not fail; recording one as a failure sends an operator \
7663             hunting a fault that does not exist"
7664        );
7665        assert_eq!(concluded.state(), AttemptState::Cleaned);
7666        assert_eq!(harness.processes.terminations.load(Ordering::SeqCst), 1);
7667        assert!(!attempt.runtime_path().exists());
7668
7669        // The registration goes with it. Without this the runner stays listed
7670        // in the target's runner settings after the process it named is gone.
7671        assert_eq!(
7672            *harness.github.deregistrations.lock().unwrap(),
7673            vec![73],
7674            "the attempt's own runner id, deleted exactly once"
7675        );
7676
7677        // And nothing is started in its place: the work it was launched for went
7678        // elsewhere, so a replacement would rebuild it every idle timeout.
7679        assert!(
7680            replacements.is_empty(),
7681            "a surplus exit must not request a replacement"
7682        );
7683    }
7684
7685    #[tokio::test]
7686    async fn a_registration_github_will_not_delete_still_concludes_the_attempt() {
7687        // The delete is best-effort by construction: the process is gone and the
7688        // slot has to come back. Holding the conclusion until GitHub cooperates
7689        // would leak a capacity slot on every unreachable moment.
7690        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7691        harness.ready().await;
7692        let attempt = harness.launch().await;
7693        harness
7694            .github
7695            .observe(GithubRunnerObservation::Registered { busy: false });
7696        harness.launcher.supervise(&harness.policy).await.unwrap();
7697
7698        harness
7699            .github
7700            .deregistration_fails
7701            .store(true, Ordering::SeqCst);
7702        harness.clock.advance_secs(11);
7703        harness
7704            .github
7705            .observe(GithubRunnerObservation::Registered { busy: false });
7706        harness.launcher.supervise(&harness.policy).await.unwrap();
7707
7708        assert_eq!(
7709            *harness.github.deregistrations.lock().unwrap(),
7710            vec![73],
7711            "the delete was attempted"
7712        );
7713        let concluded = harness.store.attempt(attempt.id).unwrap().unwrap();
7714        assert_eq!(
7715            concluded.outcome(),
7716            Some(&AttemptOutcome::ExitedIdleWithoutWork),
7717            "the attempt concluded anyway"
7718        );
7719        assert_eq!(concluded.state(), AttemptState::Cleaned);
7720        assert!(!attempt.runtime_path().exists());
7721    }
7722
7723    #[tokio::test]
7724    async fn exit_before_acceptance_returns_replacement_intent_without_launching() {
7725        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7726        harness.ready().await;
7727        let first = harness.launch().await;
7728        harness.processes.set_alive(false);
7729        harness
7730            .github
7731            .observe(GithubRunnerObservation::NotRegistered);
7732        let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
7733        let failed = harness.store.attempt(first.id).unwrap().unwrap();
7734        assert!(matches!(
7735            failed.outcome(),
7736            Some(AttemptOutcome::Failed {
7737                reason: FailureReason::ProcessExitedUnexpectedly
7738            })
7739        ));
7740        assert!(!first.runtime_path().exists());
7741
7742        assert_eq!(
7743            replacements,
7744            vec![ReplacementIntent {
7745                policy: harness.policy.id,
7746                previous_attempt: first.id,
7747                operation: "exit_before_acceptance_replacement",
7748            }]
7749        );
7750        assert_eq!(harness.store.attempts().unwrap().len(), 1);
7751        assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 1);
7752        assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 1);
7753        assert!(harness.delay.0.lock().unwrap().is_empty());
7754    }
7755
7756    /// Writes the runner's own diagnostics: always the listener's log, and the
7757    /// job worker's when `ran_a_job`. `Runner.Listener` starts `Runner.Worker`
7758    /// only for a job it has acquired.
7759    fn write_runner_diagnostics(runtime: &Path, ran_a_job: bool) {
7760        let diag = runtime.join("_diag");
7761        fs::create_dir_all(&diag).unwrap();
7762        fs::write(diag.join("Runner_20261007-211941-utc.log"), b"listener").unwrap();
7763        if ran_a_job {
7764            fs::write(diag.join("Worker_20261007-211950-utc.log"), b"worker").unwrap();
7765        }
7766    }
7767
7768    /// The macOS field case: a job of a few seconds starts and ends between two
7769    /// passes, so GitHub was never seen reporting the runner busy -- the attempt
7770    /// is still `starting` -- and the registration is already gone. It used to be
7771    /// recorded `process_exited_unexpectedly`, with a replacement runner.
7772    #[tokio::test]
7773    async fn a_job_too_short_to_be_seen_busy_is_recorded_as_completed() {
7774        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7775        harness.ready().await;
7776        let started = harness.launch().await;
7777        assert_eq!(started.state(), AttemptState::Starting);
7778        write_runner_diagnostics(started.runtime_path(), true);
7779        harness.processes.finish_successfully();
7780        harness
7781            .github
7782            .observe(GithubRunnerObservation::NotRegistered);
7783
7784        let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
7785
7786        let cleaned = harness.only_attempt();
7787        assert_eq!(cleaned.outcome(), Some(&AttemptOutcome::CompletedJob));
7788        assert_eq!(cleaned.state(), AttemptState::Cleaned);
7789        assert_eq!(cleaned.github_runner_id(), Some(73));
7790        assert!(
7791            replacements.is_empty(),
7792            "a finished job needs no replacement"
7793        );
7794        let busy_seen = harness.events.events().into_iter().any(|event| {
7795            matches!(
7796                event,
7797                AttemptEvent::State {
7798                    state: AttemptState::Busy,
7799                    ..
7800                }
7801            )
7802        });
7803        assert!(busy_seen, "the journal walks the diagram to `busy` first");
7804    }
7805
7806    /// The same from `idle`: seen registered and waiting once, then gone.
7807    #[tokio::test]
7808    async fn a_job_taken_between_two_passes_after_idle_is_recorded_as_completed() {
7809        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7810        harness.ready().await;
7811        let started = harness.launch().await;
7812        harness
7813            .github
7814            .observe(GithubRunnerObservation::Registered { busy: false });
7815        harness.launcher.supervise(&harness.policy).await.unwrap();
7816        assert_eq!(harness.only_attempt().state(), AttemptState::Idle);
7817
7818        write_runner_diagnostics(started.runtime_path(), true);
7819        harness.processes.finish_successfully();
7820        harness
7821            .github
7822            .observe(GithubRunnerObservation::NotRegistered);
7823        let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
7824
7825        assert_eq!(
7826            harness.only_attempt().outcome(),
7827            Some(&AttemptOutcome::CompletedJob)
7828        );
7829        assert!(replacements.is_empty());
7830    }
7831
7832    /// The reverse error, from the same sampling: GitHub showed the runner
7833    /// assigned, the runner never started the job (no worker log), and it exited
7834    /// 0. That is not a completed job.
7835    #[tokio::test]
7836    async fn an_assignment_the_runner_never_took_up_is_not_a_completed_job() {
7837        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7838        harness.ready().await;
7839        let started = harness.launch().await;
7840        harness
7841            .github
7842            .observe(GithubRunnerObservation::Registered { busy: true });
7843        harness.launcher.supervise(&harness.policy).await.unwrap();
7844        assert_eq!(harness.only_attempt().state(), AttemptState::Busy);
7845
7846        write_runner_diagnostics(started.runtime_path(), false);
7847        harness.processes.finish_successfully();
7848        harness
7849            .github
7850            .observe(GithubRunnerObservation::NotRegistered);
7851        harness.launcher.supervise(&harness.policy).await.unwrap();
7852
7853        let cleaned = harness.only_attempt();
7854        assert_eq!(
7855            cleaned.outcome(),
7856            Some(&AttemptOutcome::ExitedIdleWithoutWork)
7857        );
7858        assert_eq!(cleaned.state(), AttemptState::Cleaned);
7859    }
7860
7861    /// The mirror case: the runner really ran its job and exited 0 before GitHub
7862    /// took it off the list. It used to be recorded `orphaned`.
7863    #[tokio::test]
7864    async fn a_finished_job_still_listed_at_github_is_completed_and_deregistered() {
7865        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7866        harness.ready().await;
7867        let started = harness.launch().await;
7868        harness
7869            .github
7870            .observe(GithubRunnerObservation::Registered { busy: true });
7871        harness.launcher.supervise(&harness.policy).await.unwrap();
7872
7873        write_runner_diagnostics(started.runtime_path(), true);
7874        harness.processes.finish_successfully();
7875        harness
7876            .github
7877            .observe(GithubRunnerObservation::Registered { busy: true });
7878        harness.launcher.supervise(&harness.policy).await.unwrap();
7879
7880        assert_eq!(
7881            harness.only_attempt().outcome(),
7882            Some(&AttemptOutcome::CompletedJob)
7883        );
7884        assert_eq!(*harness.github.deregistrations.lock().unwrap(), vec![73]);
7885    }
7886
7887    /// A live runner journalled `busy` whose assignment GitHub withdrew goes
7888    /// back to `idle`, where the idle timeout holds it again.
7889    #[tokio::test]
7890    async fn a_live_runner_whose_assignment_was_withdrawn_goes_back_to_idle() {
7891        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7892        harness.ready().await;
7893        let started = harness.launch().await;
7894        harness
7895            .github
7896            .observe(GithubRunnerObservation::Registered { busy: true });
7897        harness.launcher.supervise(&harness.policy).await.unwrap();
7898        assert_eq!(harness.only_attempt().state(), AttemptState::Busy);
7899
7900        write_runner_diagnostics(started.runtime_path(), false);
7901        harness
7902            .github
7903            .observe(GithubRunnerObservation::Registered { busy: false });
7904        harness.launcher.supervise(&harness.policy).await.unwrap();
7905        assert_eq!(harness.only_attempt().state(), AttemptState::Idle);
7906
7907        // A runner that did start its job keeps `busy` whatever GitHub says.
7908        harness
7909            .github
7910            .observe(GithubRunnerObservation::Registered { busy: true });
7911        harness.launcher.supervise(&harness.policy).await.unwrap();
7912        write_runner_diagnostics(started.runtime_path(), true);
7913        harness
7914            .github
7915            .observe(GithubRunnerObservation::Registered { busy: false });
7916        harness.launcher.supervise(&harness.policy).await.unwrap();
7917        assert_eq!(harness.only_attempt().state(), AttemptState::Busy);
7918    }
7919
7920    /// While GitHub cannot be reached nothing is concluded, so a registration
7921    /// still listed there is removed once it answers.
7922    #[tokio::test]
7923    async fn a_clean_exit_waits_for_github_to_answer() {
7924        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7925        harness.ready().await;
7926        let started = harness.launch().await;
7927        write_runner_diagnostics(started.runtime_path(), true);
7928        harness.processes.finish_successfully();
7929        harness.github.observe(GithubRunnerObservation::Unreachable);
7930        harness.launcher.supervise(&harness.policy).await.unwrap();
7931        assert_eq!(harness.only_attempt().state(), AttemptState::Starting);
7932
7933        harness
7934            .github
7935            .observe(GithubRunnerObservation::NotRegistered);
7936        harness.launcher.supervise(&harness.policy).await.unwrap();
7937        assert_eq!(
7938            harness.only_attempt().outcome(),
7939            Some(&AttemptOutcome::CompletedJob)
7940        );
7941    }
7942
7943    /// A runner id nobody can name is not a reason to stop supervising the
7944    /// policy: the attempt is concluded the way it was before, without one.
7945    #[tokio::test]
7946    async fn a_short_job_with_no_runner_id_still_concludes() {
7947        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7948        harness.ready().await;
7949        let started = harness.launch().await;
7950        fs::remove_file(started.runtime_path().join(RUNNER_ID_FILE)).unwrap();
7951        write_runner_diagnostics(started.runtime_path(), true);
7952        harness.processes.finish_successfully();
7953        harness
7954            .github
7955            .observe(GithubRunnerObservation::NotRegistered);
7956        harness
7957            .launcher
7958            .supervise(&harness.policy)
7959            .await
7960            .expect("supervision goes on");
7961        assert!(harness.only_attempt().is_terminal());
7962    }
7963
7964    /// Without the runner's diagnostics nothing changes: a clean exit seen only
7965    /// at `starting` is still a runner that exited before taking a job.
7966    #[tokio::test]
7967    async fn without_runner_diagnostics_an_early_exit_is_still_a_failure() {
7968        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7969        harness.ready().await;
7970        harness.launch().await;
7971        harness.processes.finish_successfully();
7972        harness
7973            .github
7974            .observe(GithubRunnerObservation::NotRegistered);
7975        harness.launcher.supervise(&harness.policy).await.unwrap();
7976        assert!(matches!(
7977            harness.only_attempt().outcome(),
7978            Some(AttemptOutcome::Failed {
7979                reason: FailureReason::ProcessExitedUnexpectedly
7980            })
7981        ));
7982    }
7983
7984    #[tokio::test]
7985    async fn expired_jit_is_removed_and_does_not_reregister_after_demand_disappears() {
7986        let harness = Harness::new(
7987            FakeGithubLifecycle::default(),
7988            Arc::new(FakeDemand::answering([false])),
7989        );
7990        let id = AttemptId::new_random();
7991        let runtime = harness
7992            .launcher
7993            .app_paths
7994            .runtime_dir()
7995            .join(harness.policy.id.to_string())
7996            .join(id.to_string());
7997        fs::create_dir_all(&runtime).unwrap();
7998        let mut attempt =
7999            RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
8000        attempt.jit_received(harness.clock.now()).unwrap();
8001        harness.store.record_attempt(&attempt).unwrap();
8002        harness.clock.advance_secs(11);
8003        let replacements = harness
8004            .launcher
8005            .recover_startup(std::slice::from_ref(&harness.policy))
8006            .await
8007            .unwrap();
8008        assert_eq!(
8009            replacements,
8010            vec![ReplacementIntent {
8011                policy: harness.policy.id,
8012                previous_attempt: id,
8013                operation: "jit_expired_replacement",
8014            }]
8015        );
8016
8017        let cleaned = harness.store.attempt(id).unwrap().unwrap();
8018        assert_eq!(cleaned.state(), AttemptState::Cleaned);
8019        assert!(matches!(
8020            cleaned.outcome(),
8021            Some(AttemptOutcome::Failed {
8022                reason: FailureReason::JitExpired
8023            })
8024        ));
8025        assert!(!runtime.exists());
8026        assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
8027        assert!(harness.delay.0.lock().unwrap().is_empty());
8028    }
8029
8030    #[tokio::test]
8031    async fn expired_jit_returns_intent_but_never_launches_inside_lifecycle() {
8032        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8033        let id = AttemptId::new_random();
8034        let runtime = harness
8035            .launcher
8036            .app_paths
8037            .runtime_dir()
8038            .join("expired-with-demand");
8039        fs::create_dir_all(&runtime).unwrap();
8040        let mut attempt =
8041            RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
8042        attempt.jit_received(harness.clock.now()).unwrap();
8043        harness.store.record_attempt(&attempt).unwrap();
8044        harness.clock.advance_secs(11);
8045        let replacements = harness
8046            .launcher
8047            .recover_startup(std::slice::from_ref(&harness.policy))
8048            .await
8049            .unwrap();
8050
8051        let attempts = harness.store.attempts().unwrap();
8052        assert_eq!(attempts.len(), 1);
8053        assert_eq!(
8054            attempts
8055                .iter()
8056                .find(|attempt| attempt.id == id)
8057                .unwrap()
8058                .state(),
8059            AttemptState::Cleaned
8060        );
8061        assert_eq!(
8062            replacements,
8063            vec![ReplacementIntent {
8064                policy: harness.policy.id,
8065                previous_attempt: id,
8066                operation: "jit_expired_replacement",
8067            }]
8068        );
8069        assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
8070        assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 0);
8071        assert!(harness.delay.0.lock().unwrap().is_empty());
8072    }
8073
8074    #[tokio::test]
8075    async fn package_materialization_retries_are_bounded_and_demand_adjacent() {
8076        let persistent = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8077        persistent.packages.fail_materializations(2);
8078        persistent.ready().await;
8079        persistent.launch().await;
8080        assert_eq!(
8081            persistent.packages.materializations.load(Ordering::SeqCst),
8082            3
8083        );
8084        assert_eq!(
8085            *persistent.delay.0.lock().unwrap(),
8086            vec![Duration::from_millis(10), Duration::from_millis(20)]
8087        );
8088
8089        let gone_before_wait = Harness::new(
8090            FakeGithubLifecycle::default(),
8091            Arc::new(FakeDemand::answering([false])),
8092        );
8093        gone_before_wait.packages.fail_materializations(3);
8094        gone_before_wait.ready().await;
8095        assert!(gone_before_wait.launch_result().await.is_err());
8096        assert_eq!(
8097            gone_before_wait
8098                .packages
8099                .materializations
8100                .load(Ordering::SeqCst),
8101            1
8102        );
8103        assert!(gone_before_wait.delay.0.lock().unwrap().is_empty());
8104
8105        let gone_during_wait = Harness::new(
8106            FakeGithubLifecycle::default(),
8107            Arc::new(FakeDemand::answering([true, false])),
8108        );
8109        gone_during_wait.packages.fail_materializations(3);
8110        gone_during_wait.ready().await;
8111        assert!(gone_during_wait.launch_result().await.is_err());
8112        assert_eq!(
8113            gone_during_wait
8114                .packages
8115                .materializations
8116                .load(Ordering::SeqCst),
8117            1
8118        );
8119        assert_eq!(
8120            *gone_during_wait.delay.0.lock().unwrap(),
8121            vec![Duration::from_millis(10)]
8122        );
8123    }
8124
8125    #[tokio::test]
8126    async fn replacement_is_intent_only_and_never_launches_inside_lifecycle() {
8127        let harness = Harness::new(
8128            FakeGithubLifecycle::default(),
8129            Arc::new(FakeDemand::answering([true, false])),
8130        );
8131        harness.ready().await;
8132        let first = harness.launch().await;
8133        harness.processes.set_alive(false);
8134        harness
8135            .github
8136            .observe(GithubRunnerObservation::NotRegistered);
8137        let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
8138
8139        assert_eq!(harness.store.attempts().unwrap().len(), 1);
8140        assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 1);
8141        assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 1);
8142        assert!(harness.delay.0.lock().unwrap().is_empty());
8143        assert_eq!(
8144            replacements,
8145            vec![ReplacementIntent {
8146                policy: harness.policy.id,
8147                previous_attempt: first.id,
8148                operation: "exit_before_acceptance_replacement",
8149            }]
8150        );
8151        assert_eq!(
8152            harness.store.attempt(first.id).unwrap().unwrap().state(),
8153            AttemptState::Cleaned
8154        );
8155    }
8156
8157    #[tokio::test]
8158    async fn startup_adopts_a_live_process_and_refuses_launch_before_recovery() {
8159        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8160        let before = harness.launch_result().await;
8161        assert!(before.is_err());
8162        assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 0);
8163
8164        let id = AttemptId::new_random();
8165        let runtime = harness.launcher.app_paths.runtime_dir().join("adopt");
8166        fs::create_dir_all(&runtime).unwrap();
8167        let mut attempt =
8168            RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
8169        attempt.jit_received(harness.clock.now()).unwrap();
8170        attempt.started(4242, harness.clock.now()).unwrap();
8171        harness.store.record_attempt(&attempt).unwrap();
8172        harness.processes.set_alive(true);
8173        harness
8174            .github
8175            .observe(GithubRunnerObservation::NotRegistered);
8176        let replacements = harness
8177            .launcher
8178            .recover_startup(std::slice::from_ref(&harness.policy))
8179            .await
8180            .unwrap();
8181        assert!(replacements.is_empty());
8182        assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 0);
8183        assert!(
8184            harness
8185                .events
8186                .events()
8187                .contains(&AttemptEvent::Adopted { attempt: id })
8188        );
8189    }
8190
8191    #[tokio::test]
8192    async fn spawn_before_starting_crash_recovers_pid_then_completes_and_cleans() {
8193        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8194        let id = AttemptId::new_random();
8195        let runtime = harness
8196            .launcher
8197            .app_paths
8198            .runtime_dir()
8199            .join("spawn-before-starting");
8200        fs::create_dir_all(&runtime).unwrap();
8201        let mut attempt =
8202            RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
8203        attempt.jit_received(harness.clock.now()).unwrap();
8204        harness.store.record_attempt(&attempt).unwrap();
8205        harness.processes.set_alive(true);
8206        harness
8207            .github
8208            .observe(GithubRunnerObservation::Registered { busy: true });
8209
8210        let replacements = harness
8211            .launcher
8212            .recover_startup(std::slice::from_ref(&harness.policy))
8213            .await
8214            .unwrap();
8215        assert!(replacements.is_empty());
8216        let recovered = harness.store.attempt(id).unwrap().unwrap();
8217        assert_eq!(recovered.state(), AttemptState::Busy);
8218        assert_eq!(recovered.process_id(), Some(4242));
8219        assert_eq!(recovered.github_runner_id(), Some(73));
8220        let events = harness.events.events();
8221        let starting = events
8222            .iter()
8223            .position(|event| matches!(event, AttemptEvent::State { attempt, state: AttemptState::Starting } if *attempt == id))
8224            .unwrap();
8225        let busy = events
8226            .iter()
8227            .position(|event| matches!(event, AttemptEvent::State { attempt, state: AttemptState::Busy } if *attempt == id))
8228            .unwrap();
8229        assert!(starting < busy, "recovery skipped a legal edge: {events:?}");
8230
8231        harness.processes.finish_successfully();
8232        harness
8233            .github
8234            .observe(GithubRunnerObservation::NotRegistered);
8235        assert!(
8236            harness
8237                .launcher
8238                .supervise(&harness.policy)
8239                .await
8240                .unwrap()
8241                .is_empty()
8242        );
8243        let cleaned = harness.store.attempt(id).unwrap().unwrap();
8244        assert_eq!(cleaned.state(), AttemptState::Cleaned);
8245        assert_eq!(cleaned.outcome(), Some(&AttemptOutcome::CompletedJob));
8246        assert!(!runtime.exists());
8247    }
8248
8249    #[tokio::test]
8250    async fn failed_post_spawn_stop_keeps_capacity_until_supervision_proves_death() {
8251        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8252        harness.processes.fail_spawn_with_live_child();
8253        harness.ready().await;
8254        assert!(harness.launch_result().await.is_err());
8255
8256        let attempt = harness.only_attempt();
8257        assert_eq!(attempt.state(), AttemptState::Starting);
8258        assert_eq!(attempt.process_id(), Some(4242));
8259        assert!(attempt.outcome().is_none());
8260        assert!(attempt.state().counts_against_capacity());
8261        assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 1);
8262        assert!(harness.delay.0.lock().unwrap().is_empty());
8263
8264        harness.processes.set_alive(false);
8265        harness
8266            .github
8267            .observe(GithubRunnerObservation::NotRegistered);
8268        let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
8269        assert_eq!(replacements.len(), 1);
8270        assert_eq!(
8271            harness.store.attempt(attempt.id).unwrap().unwrap().state(),
8272            AttemptState::Cleaned
8273        );
8274    }
8275
8276    #[tokio::test]
8277    async fn remote_runner_identity_closes_both_sides_of_the_registration_crash_boundary() {
8278        for sidecar_already_present in [false, true] {
8279            let harness = Harness::new(
8280                FakeGithubLifecycle::default(),
8281                Arc::new(FakeDemand::answering([false])),
8282            );
8283            let id = AttemptId::new_random();
8284            let runtime =
8285                harness
8286                    .launcher
8287                    .app_paths
8288                    .runtime_dir()
8289                    .join(if sidecar_already_present {
8290                        "after-id-sidecar"
8291                    } else {
8292                        "before-id-sidecar"
8293                    });
8294            fs::create_dir_all(&runtime).unwrap();
8295            let mut attempt =
8296                RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
8297            if sidecar_already_present {
8298                write_runner_id(&runtime, 73).unwrap();
8299                attempt.jit_received(harness.clock.now()).unwrap();
8300            }
8301            harness.store.record_attempt(&attempt).unwrap();
8302            harness.processes.set_alive(true);
8303            harness
8304                .github
8305                .observe(GithubRunnerObservation::Registered { busy: false });
8306            harness
8307                .launcher
8308                .recover_startup(std::slice::from_ref(&harness.policy))
8309                .await
8310                .unwrap();
8311
8312            assert_eq!(read_runner_id(&runtime), Some(73));
8313            assert!(
8314                harness
8315                    .store
8316                    .attempt(id)
8317                    .unwrap()
8318                    .unwrap()
8319                    .outcome()
8320                    .is_none()
8321            );
8322            let events = harness.events.events();
8323            let recovered = events.iter().position(|event| {
8324                matches!(
8325                    event,
8326                    AttemptEvent::RemoteIdentityRecovered {
8327                        attempt,
8328                        runner_id: 73
8329                    } if *attempt == id
8330                )
8331            });
8332            assert_eq!(recovered.is_some(), !sidecar_already_present);
8333            if let Some(recovered) = recovered {
8334                let adopted = events
8335                    .iter()
8336                    .position(|event| matches!(event, AttemptEvent::Adopted { attempt } if *attempt == id))
8337                    .unwrap();
8338                assert!(
8339                    recovered < adopted,
8340                    "identity was not durable before adoption: {events:?}"
8341                );
8342            }
8343            assert!(runtime.exists());
8344        }
8345    }
8346
8347    #[tokio::test]
8348    async fn recovery_stays_closed_for_unknown_policy_and_unreachable_attempts() {
8349        let unknown = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8350        let unknown_attempt = RunnerAttempt::allocate(
8351            AttemptId::new_random(),
8352            PolicyId::from_u128(0xfeed),
8353            unknown
8354                .launcher
8355                .app_paths
8356                .runtime_dir()
8357                .join("unknown-policy"),
8358            unknown.clock.now(),
8359        );
8360        unknown.store.record_attempt(&unknown_attempt).unwrap();
8361        let expired_id = AttemptId::new_random();
8362        let expired_runtime = unknown
8363            .launcher
8364            .app_paths
8365            .runtime_dir()
8366            .join("expired-beside-unknown");
8367        fs::create_dir_all(&expired_runtime).unwrap();
8368        let mut expired = RunnerAttempt::allocate(
8369            expired_id,
8370            unknown.policy.id,
8371            expired_runtime,
8372            unknown.clock.now(),
8373        );
8374        expired.jit_received(unknown.clock.now()).unwrap();
8375        unknown.store.record_attempt(&expired).unwrap();
8376        unknown.clock.advance_secs(11);
8377        assert!(matches!(
8378            unknown
8379                .launcher
8380                .recover_startup(std::slice::from_ref(&unknown.policy))
8381                .await,
8382            Err(LifecycleError::RecoveryIncomplete)
8383        ));
8384        assert!(unknown.launch_result().await.is_err());
8385        assert_eq!(unknown.processes.spawns.load(Ordering::SeqCst), 0);
8386        let recovered_policy = fixtures::policy()
8387            .id(PolicyId::from_u128(0xfeed))
8388            .repository("octo/repo")
8389            .autoscale("home", 2)
8390            .active()
8391            .build();
8392        let pending = unknown
8393            .launcher
8394            .recover_startup(&[unknown.policy.clone(), recovered_policy])
8395            .await
8396            .unwrap();
8397        assert_eq!(
8398            pending,
8399            vec![ReplacementIntent {
8400                policy: unknown.policy.id,
8401                previous_attempt: expired_id,
8402                operation: "jit_expired_replacement",
8403            }]
8404        );
8405        assert_eq!(unknown.processes.spawns.load(Ordering::SeqCst), 0);
8406
8407        let unreachable = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8408        let id = AttemptId::new_random();
8409        let runtime = unreachable
8410            .launcher
8411            .app_paths
8412            .runtime_dir()
8413            .join("unreachable");
8414        fs::create_dir_all(&runtime).unwrap();
8415        unreachable
8416            .store
8417            .record_attempt(&RunnerAttempt::allocate(
8418                id,
8419                unreachable.policy.id,
8420                runtime,
8421                unreachable.clock.now(),
8422            ))
8423            .unwrap();
8424        unreachable
8425            .github
8426            .observe(GithubRunnerObservation::Unreachable);
8427        assert!(matches!(
8428            unreachable
8429                .launcher
8430                .recover_startup(std::slice::from_ref(&unreachable.policy))
8431                .await,
8432            Err(LifecycleError::RecoveryIncomplete)
8433        ));
8434        assert!(unreachable.launch_result().await.is_err());
8435        assert_eq!(unreachable.processes.spawns.load(Ordering::SeqCst), 0);
8436    }
8437
8438    #[tokio::test]
8439    async fn a_dead_busy_process_unknown_to_github_is_orphaned_and_cleaned() {
8440        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8441        let id = AttemptId::new_random();
8442        let runtime = harness.launcher.app_paths.runtime_dir().join("orphan");
8443        fs::create_dir_all(&runtime).unwrap();
8444        let mut attempt =
8445            RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
8446        attempt.jit_received(harness.clock.now()).unwrap();
8447        attempt.started(4242, harness.clock.now()).unwrap();
8448        attempt.assigned_job(73, harness.clock.now()).unwrap();
8449        harness.store.record_attempt(&attempt).unwrap();
8450        harness.processes.set_alive(false);
8451        harness
8452            .github
8453            .observe(GithubRunnerObservation::NotRegistered);
8454        harness
8455            .launcher
8456            .recover_startup(std::slice::from_ref(&harness.policy))
8457            .await
8458            .unwrap();
8459        let cleaned = harness.store.attempt(id).unwrap().unwrap();
8460        assert_eq!(cleaned.state(), AttemptState::Cleaned);
8461        assert_eq!(cleaned.outcome(), Some(&AttemptOutcome::Orphaned));
8462        assert!(!runtime.exists());
8463    }
8464
8465    #[tokio::test]
8466    async fn registration_timeout_journals_intent_stops_then_concludes_with_dead_reason() {
8467        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8468        harness.ready().await;
8469        let id = AttemptId::new_random();
8470        let runtime = harness.launcher.app_paths.runtime_dir().join("timeout");
8471        fs::create_dir_all(&runtime).unwrap();
8472        let mut attempt =
8473            RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
8474        attempt.jit_received(harness.clock.now()).unwrap();
8475        attempt.started(4242, harness.clock.now()).unwrap();
8476        harness.store.record_attempt(&attempt).unwrap();
8477        harness.clock.advance_secs(11);
8478        harness.processes.set_alive(true);
8479        harness
8480            .github
8481            .observe(GithubRunnerObservation::NotRegistered);
8482        let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
8483        assert_eq!(
8484            replacements,
8485            vec![ReplacementIntent {
8486                policy: harness.policy.id,
8487                previous_attempt: id,
8488                operation: "registration_timeout_replacement",
8489            }]
8490        );
8491
8492        assert_eq!(harness.processes.terminations.load(Ordering::SeqCst), 1);
8493        assert!(!harness.processes.alive.load(Ordering::SeqCst));
8494        let actions = harness.processes.actions.lock().unwrap().clone();
8495        let intent = actions
8496            .iter()
8497            .position(|action| *action == "terminate_intent")
8498            .unwrap();
8499        let signal = actions
8500            .iter()
8501            .position(|action| *action == "terminate")
8502            .unwrap();
8503        assert!(
8504            intent < signal,
8505            "intent was not durable before signal: {actions:?}"
8506        );
8507
8508        let cleaned = harness.store.attempt(id).unwrap().unwrap();
8509        assert!(matches!(
8510            cleaned.outcome(),
8511            Some(AttemptOutcome::Failed {
8512                reason: FailureReason::TerminatedAfterRegistrationTimeout
8513            })
8514        ));
8515        let events = harness.events.events();
8516        let intent = events
8517            .iter()
8518            .position(|event| matches!(event, AttemptEvent::TerminateIntent { .. }))
8519            .unwrap();
8520        let stopped = events
8521            .iter()
8522            .position(|event| matches!(event, AttemptEvent::Terminated { .. }))
8523            .unwrap();
8524        let concluded = events
8525            .iter()
8526            .position(|event| matches!(event, AttemptEvent::Concluded { .. }))
8527            .unwrap();
8528        assert!(intent < stopped && stopped < concluded, "{events:?}");
8529    }
8530
8531    #[tokio::test]
8532    async fn timeout_crash_recovery_returns_the_same_replacement_intent() {
8533        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8534        let id = AttemptId::new_random();
8535        let runtime = harness
8536            .launcher
8537            .app_paths
8538            .runtime_dir()
8539            .join("timeout-after-crash");
8540        fs::create_dir_all(&runtime).unwrap();
8541        let mut attempt =
8542            RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
8543        attempt.jit_received(harness.clock.now()).unwrap();
8544        attempt.started(4242, harness.clock.now()).unwrap();
8545        harness.store.record_attempt(&attempt).unwrap();
8546        harness.processes.intent.store(true, Ordering::SeqCst);
8547        harness.processes.set_alive(false);
8548        harness
8549            .github
8550            .observe(GithubRunnerObservation::NotRegistered);
8551
8552        let replacements = harness
8553            .launcher
8554            .recover_startup(std::slice::from_ref(&harness.policy))
8555            .await
8556            .unwrap();
8557        assert_eq!(
8558            replacements,
8559            vec![ReplacementIntent {
8560                policy: harness.policy.id,
8561                previous_attempt: id,
8562                operation: "registration_timeout_replacement",
8563            }]
8564        );
8565        let consumed = RunnerLauncher::supervise(&harness.launcher, &harness.policy)
8566            .await
8567            .unwrap();
8568        assert_eq!(consumed, replacements);
8569        assert!(
8570            RunnerLauncher::supervise(&harness.launcher, &harness.policy)
8571                .await
8572                .unwrap()
8573                .is_empty(),
8574            "startup replacement evidence must be consumed exactly once by e1"
8575        );
8576        assert!(matches!(
8577            harness.store.attempt(id).unwrap().unwrap().outcome(),
8578            Some(AttemptOutcome::Failed {
8579                reason: FailureReason::TerminatedAfterRegistrationTimeout
8580            })
8581        ));
8582    }
8583
8584    #[tokio::test]
8585    async fn terminate_intent_sync_failure_prevents_signal_and_conclusion() {
8586        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8587        let id = AttemptId::new_random();
8588        let runtime = harness
8589            .launcher
8590            .app_paths
8591            .runtime_dir()
8592            .join("timeout-sync-failure");
8593        fs::create_dir_all(&runtime).unwrap();
8594        let mut attempt =
8595            RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
8596        attempt.jit_received(harness.clock.now()).unwrap();
8597        attempt.started(4242, harness.clock.now()).unwrap();
8598        harness.store.record_attempt(&attempt).unwrap();
8599        harness.clock.advance_secs(11);
8600        harness.processes.set_alive(true);
8601        harness.processes.fail_intent();
8602        harness
8603            .github
8604            .observe(GithubRunnerObservation::NotRegistered);
8605
8606        assert!(
8607            harness
8608                .launcher
8609                .recover_startup(std::slice::from_ref(&harness.policy))
8610                .await
8611                .is_err()
8612        );
8613        assert_eq!(harness.processes.terminations.load(Ordering::SeqCst), 0);
8614        assert!(harness.processes.alive.load(Ordering::SeqCst));
8615        assert_eq!(
8616            harness.store.attempt(id).unwrap().unwrap().state(),
8617            AttemptState::Starting
8618        );
8619        assert!(!harness.events.events().iter().any(|event| matches!(
8620            event,
8621            AttemptEvent::Terminated { attempt } | AttemptEvent::Concluded { attempt, .. }
8622                if *attempt == id
8623        )));
8624    }
8625
8626    #[tokio::test]
8627    async fn diagnostics_survive_cleanup_without_the_jit_or_a_token() {
8628        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8629        harness.ready().await;
8630        let attempt = harness.launch().await;
8631        harness
8632            .github
8633            .observe(GithubRunnerObservation::Registered { busy: false });
8634        harness.launcher.supervise(&harness.policy).await.unwrap();
8635        harness.clock.advance_secs(11);
8636        harness.processes.set_alive(false);
8637        harness
8638            .github
8639            .observe(GithubRunnerObservation::NotRegistered);
8640        harness.launcher.supervise(&harness.policy).await.unwrap();
8641        let diagnostic = fs::read_to_string(
8642            harness
8643                .launcher
8644                .diagnostics_root
8645                .join(format!("{}.log", attempt.id)),
8646        )
8647        .unwrap();
8648        assert!(diagnostic.contains("exited_idle_without_work"));
8649        assert!(!diagnostic.contains(JIT));
8650        assert!(!diagnostic.contains("ghp_"));
8651        assert!(!attempt.runtime_path().exists());
8652    }
8653
8654    #[test]
8655    fn native_process_listing_never_contains_jit_and_handoffs_never_survive() {
8656        let root = tempfile::tempdir().unwrap();
8657        let policy = fixtures::policy()
8658            .repository("octo/repo")
8659            .autoscale("home", 1)
8660            .active()
8661            .build();
8662        let runtime = root.path().join("successful");
8663        fs::create_dir_all(&runtime).unwrap();
8664        let processes = NativeProcesses::new();
8665        let config = EncodedJitConfig::new(JIT);
8666        let handoff =
8667            RestrictiveHandoff::create(&runtime, SecretString::from(config.expose().to_owned()))
8668                .unwrap();
8669        let mut child = native_inspection_spec()
8670            .spawn_runner_with_handoff(&handoff)
8671            .expect("native child starts");
8672        let pid = child.pid();
8673        handoff.delete().unwrap();
8674        let command_line = native_command_line(pid);
8675        assert!(
8676            !command_line.contains(JIT),
8677            "the encoded JIT configuration appeared in the native process listing"
8678        );
8679        assert_no_jit_file(&runtime);
8680        child
8681            .stop(Duration::from_secs(1))
8682            .expect("native child stops");
8683
8684        let failed_runtime = root.path().join("failed");
8685        fs::create_dir_all(&failed_runtime).unwrap();
8686        let failed = RunnerAttempt::allocate(
8687            AttemptId::new_random(),
8688            policy.id,
8689            &failed_runtime,
8690            FakeClock::default().now(),
8691        );
8692        assert!(
8693            processes
8694                .spawn(&failed, &EncodedJitConfig::new(JIT))
8695                .is_err(),
8696            "a runtime with no runner executable must fail"
8697        );
8698        assert_no_jit_file(&failed_runtime);
8699        processes
8700            .record_terminate_intent(&failed)
8701            .expect("the intent file and its directory entry are durably synced");
8702        assert_eq!(
8703            fs::read(NativeProcesses::intent_path(&failed)).unwrap(),
8704            b"registration-timeout\n"
8705        );
8706    }
8707
8708    #[test]
8709    fn post_spawn_boundaries_are_bounded_durable_and_never_retry_jit() {
8710        let root = tempfile::tempdir().unwrap();
8711        let policy = fixtures::policy()
8712            .repository("octo/repo")
8713            .autoscale("home", 1)
8714            .active()
8715            .build();
8716        let processes = NativeProcesses::new();
8717        processes.use_long_lived_test_listener();
8718        for (index, boundary) in [
8719            PostSpawnBoundary::HandoffDelete,
8720            PostSpawnBoundary::IdentitySerialize,
8721            PostSpawnBoundary::IdentityWrite,
8722            PostSpawnBoundary::ChildMapInsert,
8723        ]
8724        .into_iter()
8725        .enumerate()
8726        {
8727            let runtime = root.path().join(format!("post-spawn-{index}"));
8728            let bin = runtime.join("bin");
8729            fs::create_dir_all(&bin).unwrap();
8730            #[cfg(windows)]
8731            let listener = bin.join("Runner.Listener.exe");
8732            #[cfg(not(windows))]
8733            let listener = bin.join("Runner.Listener");
8734            fs::copy(std::env::current_exe().unwrap(), &listener).unwrap();
8735            let attempt = RunnerAttempt::allocate(
8736                AttemptId::new_random(),
8737                policy.id,
8738                &runtime,
8739                FakeClock::default().now(),
8740            );
8741            processes.fail_post_spawn_at(boundary);
8742            let failure = processes
8743                .spawn(&attempt, &EncodedJitConfig::new(JIT))
8744                .expect_err("fault must cross the post-spawn cleanup path");
8745            assert!(!failure.retryable, "{boundary:?} allowed duplicate retry");
8746            assert!(
8747                !processes.is_alive(&attempt).unwrap(),
8748                "{boundary:?} left a child"
8749            );
8750            assert!(!NativeProcesses::identity_path(&attempt).exists());
8751            assert_no_jit_file(&runtime);
8752        }
8753        assert_eq!(processes.post_spawn_reaps.load(Ordering::SeqCst), 4);
8754
8755        let runtime = root.path().join("identity-and-stop-fail");
8756        let bin = runtime.join("bin");
8757        fs::create_dir_all(&bin).unwrap();
8758        #[cfg(windows)]
8759        let listener = bin.join("Runner.Listener.exe");
8760        #[cfg(not(windows))]
8761        let listener = bin.join("Runner.Listener");
8762        fs::copy(std::env::current_exe().unwrap(), &listener).unwrap();
8763        let attempt = RunnerAttempt::allocate(
8764            AttemptId::new_random(),
8765            policy.id,
8766            &runtime,
8767            FakeClock::default().now(),
8768        );
8769        // The first fault rejects the normal identity write; the second rejects
8770        // its retry after the first stop fails. The fallback sidecar must make
8771        // the live-child result durable without an unbounded reap loop.
8772        processes.fail_post_spawn_at(PostSpawnBoundary::IdentityWrite);
8773        processes.fail_post_spawn_at(PostSpawnBoundary::IdentityWrite);
8774        processes.fail_next_post_spawn_stop();
8775        let failure = processes
8776            .spawn(&attempt, &EncodedJitConfig::new(JIT))
8777            .expect_err("the identity boundary must fail closed");
8778        assert!(failure.live_pid.is_some());
8779        assert_long_lived_listener_ready(&processes, &attempt);
8780        assert!(processes.is_alive(&attempt).unwrap());
8781        assert!(!NativeProcesses::identity_path(&attempt).exists());
8782        assert!(NativeProcesses::fallback_identity_path(&attempt).is_file());
8783        assert_eq!(processes.post_spawn_reaps.load(Ordering::SeqCst), 4);
8784        processes.terminate(&attempt).unwrap();
8785
8786        let runtime = root.path().join("persistent-stop-and-identity-failures");
8787        let bin = runtime.join("bin");
8788        fs::create_dir_all(&bin).unwrap();
8789        #[cfg(windows)]
8790        let listener = bin.join("Runner.Listener.exe");
8791        #[cfg(not(windows))]
8792        let listener = bin.join("Runner.Listener");
8793        fs::copy(std::env::current_exe().unwrap(), &listener).unwrap();
8794        let mut unresolved = RunnerAttempt::allocate(
8795            AttemptId::new_random(),
8796            policy.id,
8797            &runtime,
8798            FakeClock::default().now(),
8799        );
8800        for _ in 0..3 {
8801            processes.fail_post_spawn_at(PostSpawnBoundary::IdentityWrite);
8802        }
8803        processes.fail_post_spawn_stops(MAX_POST_SPAWN_STOP_ATTEMPTS);
8804        let failure = processes
8805            .spawn(&unresolved, &EncodedJitConfig::new(JIT))
8806            .expect_err("bounded cleanup must return even when every stop errors");
8807        let pid = failure
8808            .live_pid
8809            .expect("the owned child remains supervised in this invocation");
8810        assert!(matches!(failure.reason, FailureReason::Other(_)));
8811        assert_long_lived_listener_ready(&processes, &unresolved);
8812        unresolved.jit_received(FakeClock::default().now()).unwrap();
8813        unresolved.started(pid, FakeClock::default().now()).unwrap();
8814        let journal = SqliteStore::open_in_memory().unwrap();
8815        journal.record_attempt(&unresolved).unwrap();
8816        let recovered = journal.attempt(unresolved.id).unwrap().unwrap();
8817        assert_eq!(recovered.process_id(), Some(pid));
8818        assert_eq!(recovered.state(), AttemptState::Starting);
8819        assert!(processes.is_alive(&unresolved).unwrap());
8820        assert!(!NativeProcesses::identity_path(&unresolved).exists());
8821        assert!(!NativeProcesses::fallback_identity_path(&unresolved).exists());
8822        assert_eq!(
8823            fs::read_to_string(NativeProcesses::unresolved_process_path(&unresolved)).unwrap(),
8824            pid.to_string(),
8825            "bounded cleanup must leave durable unresolved-process evidence before returning"
8826        );
8827        assert!(
8828            NativeProcesses::new().is_alive(&recovered).is_err(),
8829            "restart must fail closed on the durable starting/PID journal rather than trust a bare PID"
8830        );
8831        processes.terminate(&unresolved).unwrap();
8832
8833        let runtime = root.path().join("post-spawn-stop-failed");
8834        let bin = runtime.join("bin");
8835        fs::create_dir_all(&bin).unwrap();
8836        #[cfg(windows)]
8837        let listener = bin.join("Runner.Listener.exe");
8838        #[cfg(not(windows))]
8839        let listener = bin.join("Runner.Listener");
8840        fs::copy(std::env::current_exe().unwrap(), &listener).unwrap();
8841        let attempt = RunnerAttempt::allocate(
8842            AttemptId::new_random(),
8843            policy.id,
8844            &runtime,
8845            FakeClock::default().now(),
8846        );
8847        processes.fail_post_spawn_at(PostSpawnBoundary::ChildMapInsert);
8848        processes.fail_next_post_spawn_stop();
8849        let failure = processes
8850            .spawn(&attempt, &EncodedJitConfig::new(JIT))
8851            .expect_err("the injected stop failure must preserve supervision");
8852        let live_pid = failure
8853            .live_pid
8854            .expect("live PID is returned to the journal");
8855        assert!(!failure.retryable);
8856        assert_long_lived_listener_ready(&processes, &attempt);
8857        assert!(NativeProcesses::identity_path(&attempt).is_file());
8858        assert_eq!(
8859            NativeProcesses::read_identity(&attempt)
8860                .unwrap()
8861                .unwrap()
8862                .pid(),
8863            live_pid
8864        );
8865        assert_eq!(processes.post_spawn_reaps.load(Ordering::SeqCst), 4);
8866        processes.terminate(&attempt).unwrap();
8867    }
8868
8869    #[test]
8870    #[ignore = "spawned only as the platform-stable native listener fixture"]
8871    fn long_lived_native_listener_helper() {
8872        let ready = std::env::var_os("RUNNER_MANAGER_TEST_LISTENER_READY")
8873            .map(PathBuf::from)
8874            .expect("the parent supplies the readiness path");
8875        fs::write(ready, b"ready\n").expect("the listener publishes readiness");
8876        std::thread::sleep(Duration::from_secs(30));
8877    }
8878
8879    fn assert_long_lived_listener_ready(processes: &NativeProcesses, attempt: &RunnerAttempt) {
8880        let ready = attempt.runtime_path().join(TEST_LISTENER_READY);
8881        let deadline = std::time::Instant::now() + Duration::from_secs(5);
8882        loop {
8883            if ready.is_file() {
8884                assert_eq!(fs::read(&ready).unwrap(), b"ready\n");
8885                return;
8886            }
8887            assert!(
8888                processes.is_alive(attempt).unwrap(),
8889                "the native listener exited before publishing readiness"
8890            );
8891            assert!(
8892                std::time::Instant::now() < deadline,
8893                "the native listener stayed alive but never published readiness"
8894            );
8895            std::thread::sleep(Duration::from_millis(10));
8896        }
8897    }
8898
8899    #[tokio::test]
8900    async fn every_production_launch_prunes_under_the_same_allocation_guard() {
8901        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8902        harness.ready().await;
8903        assert_eq!(harness.packages.prunes.load(Ordering::SeqCst), 0);
8904        harness.launch().await;
8905        assert_eq!(harness.packages.prunes.load(Ordering::SeqCst), 1);
8906        assert_eq!(
8907            *harness.packages.prune_currents.lock().unwrap(),
8908            vec![harness.packages.version.clone()],
8909            "the leased current version is an exclusion, never the prune target"
8910        );
8911    }
8912
8913    fn assert_no_jit_file(runtime: &Path) {
8914        for entry in fs::read_dir(runtime).unwrap() {
8915            let path = entry.unwrap().path();
8916            if path.is_file() {
8917                let bytes = fs::read(&path).unwrap();
8918                assert!(
8919                    !bytes
8920                        .windows(JIT.len())
8921                        .any(|window| window == JIT.as_bytes()),
8922                    "a JIT payload survived in a runtime file"
8923                );
8924            }
8925        }
8926    }
8927
8928    #[test]
8929    fn production_listener_command_uses_the_supported_jit_contract() {
8930        let runtime = Path::new("runtime");
8931        let spec = runner_listener_spec(
8932            PathBuf::from("Runner.Listener"),
8933            runtime,
8934            Vec::new(),
8935            &RunnerEnv::default(),
8936        );
8937        let arguments: Vec<_> = spec
8938            .arguments()
8939            .iter()
8940            .map(|argument| argument.to_string_lossy().into_owned())
8941            .collect();
8942
8943        assert_eq!(arguments, ["run"]);
8944        assert!(
8945            !arguments
8946                .iter()
8947                .any(|argument| argument == "--jit-config-file"),
8948            "the obsolete file option would be rejected by Runner.Listener 2.336.0"
8949        );
8950    }
8951
8952    /// What a production runner starts with: the platform defaults, the host's
8953    /// `runner.env` over them, the attempt's own temporary directory, and
8954    /// normal scheduling.
8955    #[test]
8956    fn production_listener_layers_runner_env_over_the_platform_defaults() {
8957        let root = tempfile::tempdir().unwrap();
8958        let runtime = root.path().join("attempt");
8959        let host_env =
8960            RunnerEnv::parse("ELECTRON_CACHE=/cache/electron\nHOME=/operator/chose/this\n")
8961                .unwrap();
8962        let caches = vec![
8963            (
8964                "ELECTRON_CACHE",
8965                std::ffi::OsString::from("/rman/_cache/o/r/electron"),
8966            ),
8967            (
8968                "npm_config_cache",
8969                std::ffi::OsString::from("/rman/_cache/o/r/npm"),
8970            ),
8971        ];
8972        let spec = runner_listener_spec(
8973            PathBuf::from("Runner.Listener"),
8974            &runtime,
8975            caches,
8976            &host_env,
8977        );
8978        let env = |name: &str| spec.configured_env(name).map(PathBuf::from);
8979
8980        assert!(spec.expects_normal_scheduling());
8981        assert_eq!(
8982            env("ELECTRON_CACHE"),
8983            Some(PathBuf::from("/cache/electron")),
8984            "runner.env must win over a dependency cache of the same name"
8985        );
8986        assert_eq!(
8987            env("npm_config_cache"),
8988            Some(PathBuf::from("/rman/_cache/o/r/npm"))
8989        );
8990        assert_eq!(
8991            env("HOME"),
8992            Some(PathBuf::from("/operator/chose/this")),
8993            "runner.env must win over a platform default of the same name"
8994        );
8995        for tmp in ["TMPDIR", "TEMP", "TMP"] {
8996            assert_eq!(env(tmp), Some(runtime.join("tmp")), "{tmp}");
8997        }
8998
8999        let home = runtime.join(runner_env::RUNNER_HOME_DIR);
9000        if cfg!(windows) {
9001            // Each runner's own profile, created inside its attempt, so that
9002            // concurrent jobs stop sharing the service account's.
9003            assert_eq!(env("USERPROFILE"), Some(home.clone()));
9004            assert_eq!(env("APPDATA"), Some(home.join(r"AppData\Roaming")));
9005            assert_eq!(env("LOCALAPPDATA"), Some(home.join(r"AppData\Local")));
9006            assert!(home.join(r"AppData\Roaming").is_dir());
9007            assert!(home.join(r"AppData\Local").is_dir());
9008        } else {
9009            // Unix keeps the account's own profile.
9010            assert_eq!(env("USERPROFILE"), None);
9011            assert!(!home.exists());
9012        }
9013    }
9014
9015    /// Every step shell a runner starts inherits PowerShell's telemetry
9016    /// opt-out from the listener, unless the daemon or `runner.env` decided
9017    /// otherwise; see [`runner_env::POWERSHELL_TELEMETRY_OPTOUT`].
9018    #[test]
9019    fn production_listener_opts_powershell_out_of_telemetry() {
9020        let root = tempfile::tempdir().unwrap();
9021        let runtime = root.path().join("attempt");
9022        let listener = |host_env: &RunnerEnv| {
9023            runner_listener_spec(
9024                PathBuf::from("Runner.Listener"),
9025                &runtime,
9026                Vec::new(),
9027                host_env,
9028            )
9029        };
9030        let optout = |spec: &SpawnSpec| {
9031            spec.configured_env(runner_env::POWERSHELL_TELEMETRY_OPTOUT)
9032                .map(OsStr::to_os_string)
9033        };
9034
9035        let spec = listener(&RunnerEnv::default());
9036        if std::env::var_os(runner_env::POWERSHELL_TELEMETRY_OPTOUT)
9037            .is_some_and(|value| !value.is_empty())
9038        {
9039            // The daemon's own value is inherited as it is.
9040            assert_eq!(optout(&spec), None);
9041        } else {
9042            assert_eq!(
9043                optout(&spec),
9044                Some(runner_env::POWERSHELL_TELEMETRY_OPTOUT_VALUE.into())
9045            );
9046        }
9047
9048        let operator = RunnerEnv::parse("POWERSHELL_TELEMETRY_OPTOUT=0\n").unwrap();
9049        assert_eq!(
9050            optout(&listener(&operator)),
9051            Some("0".into()),
9052            "runner.env must win over the telemetry opt-out"
9053        );
9054    }
9055
9056    #[test]
9057    fn an_invalid_runner_env_fails_the_launch_without_echoing_a_value() {
9058        let root = tempfile::tempdir().unwrap();
9059        let path = runner_env::path_in(root.path());
9060        fs::write(&path, "GOOD=1\nghp_notarealtokenbutshapedlikeone\n").unwrap();
9061        let processes = NativeProcesses::new().with_runner_env_file(path.clone());
9062        let failure = processes.host_env().unwrap_err().to_string();
9063        assert!(
9064            failure.contains("runner.env") && failure.contains("line 2"),
9065            "{failure}"
9066        );
9067        assert!(
9068            !failure.contains("ghp_"),
9069            "the failure echoed a value: {failure}"
9070        );
9071
9072        // `prepare` runs before the JIT registration, so a broken file is
9073        // refused there, before GitHub is asked for anything.
9074        let policy = fixtures::policy()
9075            .repository("octo/repo")
9076            .autoscale("home", 1)
9077            .active()
9078            .build();
9079        let attempt = RunnerAttempt::allocate(
9080            AttemptId::new_random(),
9081            policy.id,
9082            root.path(),
9083            FakeClock::default().now(),
9084        );
9085        assert!(processes.prepare(&attempt, &policy).is_err());
9086
9087        fs::write(&path, "GOOD=1\n").unwrap();
9088        assert!(processes.prepare(&attempt, &policy).is_ok());
9089        assert_eq!(processes.host_env().unwrap().get("GOOD"), Some("1"));
9090        assert!(NativeProcesses::new().host_env().unwrap().is_empty());
9091    }
9092
9093    /// The wiring from `prepare` (which sees the policy) to `spawn` (which
9094    /// does not): the policy's repository decides the namespace, and a broken
9095    /// `caches.toml` is refused before GitHub is asked for anything.
9096    #[test]
9097    fn prepare_hands_the_policys_cache_namespace_to_the_launch() {
9098        let root = tempfile::tempdir().unwrap();
9099        let runner_root = root.path().join("rman");
9100        let runtime = runner_root.join("a1");
9101        fs::create_dir_all(runtime.join("bin")).unwrap();
9102        let config = runner_manager_platform::dependency_cache::config_path_in(root.path());
9103        let source_root = runner_root.clone();
9104        let processes = NativeProcesses::new().with_dependency_caches(DependencyCaches::new(
9105            config.clone(),
9106            Arc::new(move || Some(source_root.clone())),
9107            None,
9108        ));
9109        let policy = fixtures::policy()
9110            .repository("Octo/Repo")
9111            .autoscale("home", 1)
9112            .active()
9113            .build();
9114        let attempt = RunnerAttempt::allocate(
9115            AttemptId::new_random(),
9116            policy.id,
9117            &runtime,
9118            FakeClock::default().now(),
9119        );
9120        assert!(
9121            processes
9122                .cache_env(&attempt, None, &RunnerEnv::default())
9123                .is_empty(),
9124            "a launch with no prepared target, such as a bare `spawn`, has no cache"
9125        );
9126        let prepared = processes.prepare(&attempt, &policy).unwrap();
9127        let variables = processes.cache_env(
9128            &attempt,
9129            prepared.cache_target.as_ref(),
9130            &RunnerEnv::default(),
9131        );
9132        let npm = variables
9133            .iter()
9134            .find(|(name, _)| *name == "npm_config_cache")
9135            .map(|(_, value)| PathBuf::from(value));
9136        assert_eq!(
9137            npm,
9138            Some(
9139                runner_root
9140                    .join("_cache")
9141                    .join("octo")
9142                    .join("repo")
9143                    .join("npm")
9144            )
9145        );
9146
9147        fs::write(&config, "[tools]\nnot-a-tool = true\n").unwrap();
9148        let refused = processes
9149            .prepare(&attempt, &policy)
9150            .unwrap_err()
9151            .to_string();
9152        assert!(refused.contains("caches.toml"), "{refused}");
9153    }
9154
9155    #[cfg(windows)]
9156    fn native_inspection_spec() -> SpawnSpec {
9157        SpawnSpec::new("powershell.exe").args([
9158            "-NoProfile",
9159            "-NonInteractive",
9160            "-Command",
9161            "Start-Sleep -Seconds 30",
9162        ])
9163    }
9164
9165    #[cfg(unix)]
9166    fn native_inspection_spec() -> SpawnSpec {
9167        SpawnSpec::new("/bin/sh").args(["-c", "sleep 30"])
9168    }
9169
9170    #[cfg(windows)]
9171    fn native_command_line(pid: u32) -> String {
9172        let output = std::process::Command::new("powershell.exe")
9173            .args([
9174                "-NoProfile",
9175                "-NonInteractive",
9176                "-Command",
9177                &format!("(Get-CimInstance Win32_Process -Filter 'ProcessId = {pid}').CommandLine"),
9178            ])
9179            .output()
9180            .expect("PowerShell can inspect the native child");
9181        assert!(output.status.success(), "native process inspection failed");
9182        String::from_utf8(output.stdout).expect("Windows command lines are Unicode")
9183    }
9184
9185    #[cfg(target_os = "linux")]
9186    fn native_command_line(pid: u32) -> String {
9187        fs::read(format!("/proc/{pid}/cmdline"))
9188            .map(|bytes| String::from_utf8_lossy(&bytes).replace('\0', " "))
9189            .expect("/proc exposes the native child command line")
9190    }
9191
9192    #[cfg(target_os = "macos")]
9193    fn native_command_line(pid: u32) -> String {
9194        let output = std::process::Command::new("ps")
9195            .args(["-o", "command=", "-p", &pid.to_string()])
9196            .output()
9197            .expect("ps can inspect the native child");
9198        assert!(output.status.success(), "native process inspection failed");
9199        String::from_utf8(output.stdout).expect("the command line is UTF-8")
9200    }
9201
9202    // -- c2: persistent slot allocation -------------------------------------
9203
9204    #[tokio::test]
9205    async fn a_persistent_repository_leases_s1_and_journals_it_before_any_github_effect() {
9206        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
9207            .with_persistent_workspace(2);
9208        harness.github.watch_journal(Arc::clone(&harness.store));
9209        harness.ready().await;
9210
9211        let attempt = harness.launch().await;
9212
9213        assert_eq!(
9214            attempt.workspace(),
9215            AttemptWorkspace::persistent_slot(nz(1)),
9216            "the lowest free slot is leased"
9217        );
9218        assert_eq!(attempt.runtime_path(), harness.slot_path(1));
9219        assert!(attempt.holds_slot_lease());
9220        // The exact runtime path is journalled, not re-derived later.
9221        assert_eq!(
9222            harness.attempt(attempt.id).runtime_path(),
9223            harness.slot_path(1)
9224        );
9225
9226        // Step 7 of "Slot allocation": the lease exists before GitHub is asked
9227        // for anything, and the runner's work folder stays the relative `_work`
9228        // the slot root is laid out around.
9229        let facts = harness.github.registration_facts();
9230        assert_eq!(facts.len(), 1);
9231        assert_eq!(
9232            facts[0].leased_slots,
9233            vec![1],
9234            "the lease was journalled first"
9235        );
9236        assert_eq!(facts[0].work_folder, DEFAULT_WORK_FOLDER);
9237    }
9238
9239    #[tokio::test]
9240    async fn a_terminal_but_uncleaned_attempt_keeps_its_slot_without_holding_capacity() {
9241        let harness = Harness::new(
9242            FakeGithubLifecycle::default().fail(true),
9243            Arc::new(PersistentDemand),
9244        )
9245        .with_persistent_workspace(2);
9246        harness.ready().await;
9247
9248        // A terminal JIT refusal concludes the attempt without cleaning it.
9249        harness.launch_result().await.unwrap_err();
9250        let first = harness.store.attempts().unwrap().remove(0);
9251        assert_eq!(first.state(), AttemptState::Failed);
9252        assert!(
9253            !first.state().counts_against_capacity(),
9254            "a concluded attempt is invisible to host capacity"
9255        );
9256        assert!(
9257            first.holds_slot_lease(),
9258            "and still owns its directory, so its slot is not free"
9259        );
9260
9261        let second = harness.launch().await;
9262        assert_eq!(second.workspace(), AttemptWorkspace::persistent_slot(nz(2)));
9263        assert_eq!(second.runtime_path(), harness.slot_path(2));
9264        assert_eq!(
9265            harness
9266                .store
9267                .slot_leases_for_policy(harness.policy.id)
9268                .unwrap()
9269                .len(),
9270            2
9271        );
9272    }
9273
9274    #[tokio::test]
9275    async fn two_sequential_allocations_at_capacity_one_reuse_s1_and_its_retained_work() {
9276        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
9277            .with_persistent_workspace(1);
9278        harness.ready().await;
9279
9280        let first = harness.launch().await;
9281        assert_eq!(first.runtime_path(), harness.slot_path(1));
9282
9283        // What a job leaves behind, at the path the runner writes it to.
9284        let checkout = harness.slot_path(1).join(DEFAULT_WORK_FOLDER).join("repo");
9285        fs::create_dir_all(&checkout).unwrap();
9286        fs::write(checkout.join("checkout.txt"), b"from the first job").unwrap();
9287
9288        harness.cleanup_retaining_work(first.id).await;
9289
9290        let second = harness.launch().await;
9291        assert_ne!(second.id, first.id);
9292        assert_eq!(
9293            second.workspace(),
9294            AttemptWorkspace::persistent_slot(nz(1)),
9295            "a released slot is leased again rather than skipped"
9296        );
9297        assert_eq!(
9298            second.runtime_path(),
9299            first.runtime_path(),
9300            "the same slot is the same exact path"
9301        );
9302        assert_eq!(
9303            fs::read_to_string(checkout.join("checkout.txt")).unwrap(),
9304            "from the first job",
9305            "the retained job workspace survived the second allocation"
9306        );
9307        // The attempt's own runner material was recreated for this attempt.
9308        assert!(harness.slot_path(1).join("runner-package").exists());
9309    }
9310
9311    #[tokio::test]
9312    async fn lowering_capacity_leaves_higher_slots_alone_and_raising_it_permits_them_again() {
9313        let mut harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
9314            .with_persistent_workspace(2);
9315        harness.ready().await;
9316
9317        let first = harness.launch().await;
9318        let second = harness.launch().await;
9319        assert_eq!(second.runtime_path(), harness.slot_path(2));
9320        let kept = harness
9321            .slot_path(2)
9322            .join(DEFAULT_WORK_FOLDER)
9323            .join("kept.txt");
9324        fs::create_dir_all(kept.parent().unwrap()).unwrap();
9325        fs::write(&kept, b"s2 was here").unwrap();
9326        harness.cleanup_retaining_work(second.id).await;
9327
9328        // The operator lowers the ceiling while s1 is still leased.
9329        harness.policy.set_max_capacity(nz(1)).unwrap();
9330        let refusal = harness.launch_result().await.unwrap_err().to_string();
9331        assert!(
9332            refusal.contains("s1 to s1"),
9333            "the refusal names the ceiling it reached: {refusal}"
9334        );
9335        assert!(
9336            harness.slot_path(2).exists() && kept.exists(),
9337            "lowering capacity deletes nothing; the higher slot is merely unusable"
9338        );
9339
9340        // Raising it again makes the free higher slot available.
9341        harness.policy.set_max_capacity(nz(2)).unwrap();
9342        let third = harness.launch().await;
9343        assert_eq!(third.workspace(), AttemptWorkspace::persistent_slot(nz(2)));
9344        assert_eq!(third.runtime_path(), harness.slot_path(2));
9345        assert_eq!(fs::read_to_string(&kept).unwrap(), "s2 was here");
9346        assert!(first.holds_slot_lease(), "s1 was never disturbed");
9347    }
9348
9349    #[tokio::test]
9350    async fn organization_and_ephemeral_policies_never_enter_slot_allocation() {
9351        for policy in [
9352            fixtures::policy()
9353                .organization("octo")
9354                .autoscale("home", 2)
9355                .active()
9356                .build(),
9357            fixtures::policy()
9358                .repository("octo/repo")
9359                .autoscale("home", 2)
9360                .active()
9361                .build(),
9362        ] {
9363            let mut harness =
9364                Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
9365                    .with_host_runner_root();
9366            assert_eq!(policy.workspace_policy(), &WorkspacePolicy::Ephemeral);
9367            harness.policy = policy;
9368            harness.ready().await;
9369
9370            let attempt = harness.launch().await;
9371            assert_eq!(attempt.workspace(), AttemptWorkspace::Ephemeral);
9372            assert_eq!(attempt.workspace().slot_number(), None);
9373            assert!(!attempt.holds_slot_lease());
9374            assert_eq!(
9375                attempt.runtime_path().parent().unwrap(),
9376                harness.host_root(),
9377                "a disposable attempt is a child of the host root, never of a slot"
9378            );
9379            assert!(
9380                harness
9381                    .store
9382                    .slot_leases_for_policy(harness.policy.id)
9383                    .unwrap()
9384                    .is_empty()
9385            );
9386        }
9387    }
9388
9389    #[tokio::test]
9390    async fn two_concurrent_allocations_never_share_a_slot() {
9391        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
9392            .with_persistent_workspace(2);
9393        harness.github.watch_journal(Arc::clone(&harness.store));
9394        harness.ready().await;
9395
9396        // Both allocators race for the same host allocation lock, which is what
9397        // orders slot *selection*; each one then journals its lease before it
9398        // asks GitHub for anything.
9399        let (first, second) = tokio::join!(harness.launch_result(), harness.launch_result());
9400        let first = first.unwrap();
9401        let second = second.unwrap();
9402
9403        let slots: BTreeSet<u16> = [&first, &second]
9404            .iter()
9405            .map(|attempt| {
9406                attempt
9407                    .workspace()
9408                    .slot_number()
9409                    .expect("a persistent attempt leases a slot")
9410            })
9411            .collect();
9412        assert_eq!(slots, BTreeSet::from([1, 2]), "one slot each, never shared");
9413        assert_ne!(first.runtime_path(), second.runtime_path());
9414        assert_eq!(
9415            harness
9416                .store
9417                .slot_leases_for_policy(harness.policy.id)
9418                .unwrap()
9419                .len(),
9420            2
9421        );
9422
9423        // Every registration saw *its own* lease already in the journal. Reading
9424        // the whole journal and asking only that it be non-empty would pass on
9425        // the other allocator's lease, which is precisely the ordering bug this
9426        // test exists to exclude.
9427        let facts = harness.github.registration_facts();
9428        assert_eq!(facts.len(), 2);
9429        for fact in facts {
9430            let attempt = [&first, &second]
9431                .into_iter()
9432                .find(|attempt| runner_name(attempt.id) == fact.runner_name)
9433                .expect("every registration belongs to one of the two attempts");
9434            let slot = attempt
9435                .workspace()
9436                .slot_number()
9437                .expect("a persistent attempt leases a slot");
9438            assert!(
9439                fact.leased_slots.contains(&slot),
9440                "a JIT request never precedes its own lease: s{slot} not in {:?}",
9441                fact.leased_slots
9442            );
9443            assert_eq!(fact.work_folder, DEFAULT_WORK_FOLDER);
9444        }
9445    }
9446
9447    #[tokio::test]
9448    async fn the_database_is_the_final_fence_against_two_attempts_in_one_slot() {
9449        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
9450            .with_persistent_workspace(2);
9451        harness.ready().await;
9452        let first = harness.launch().await;
9453
9454        // What a second allocator that lost the race would write: the lock
9455        // orders selection, and this index is what catches a writer the lock
9456        // could not see.
9457        let clash = RunnerAttempt::allocate_in(
9458            AttemptId::new_random(),
9459            harness.policy.id,
9460            first.runtime_path(),
9461            AttemptWorkspace::persistent_slot(nz(1)),
9462            harness.clock.now(),
9463        );
9464        assert!(matches!(
9465            harness.store.record_attempt(&clash).unwrap_err(),
9466            StoreError::SlotAlreadyLeased { slot: 1, .. }
9467        ));
9468
9469        // And the launcher reports it as itself rather than as a generic
9470        // journal failure, so the operator reads what actually happened.
9471        let error = harness.launcher.record_allocation(&clash).unwrap_err();
9472        let rendered = error.to_string();
9473        assert!(rendered.contains("slot s1"), "{rendered}");
9474        assert!(rendered.contains("nothing was written"), "{rendered}");
9475        assert_eq!(
9476            harness.store.attempts().unwrap().len(),
9477            1,
9478            "the losing allocator journalled nothing"
9479        );
9480    }
9481
9482    #[test]
9483    fn slot_selection_fills_the_lowest_gap_and_stops_at_the_ceiling() {
9484        let leased = |slots: &[u16]| -> Vec<RunnerAttempt> {
9485            slots
9486                .iter()
9487                .map(|slot| {
9488                    RunnerAttempt::allocate_in(
9489                        AttemptId::new_random(),
9490                        fixtures::POLICY_ID,
9491                        format!("/srv/rman/acme/s{slot}"),
9492                        AttemptWorkspace::persistent_slot(nz(*slot)),
9493                        fixtures::created_at(),
9494                    )
9495                })
9496                .collect()
9497        };
9498
9499        assert_eq!(lowest_free_slot(&[], nz(1)), Some(nz(1)));
9500        assert_eq!(lowest_free_slot(&leased(&[1]), nz(4)), Some(nz(2)));
9501        // The gap a released middle slot leaves is filled before the tail.
9502        assert_eq!(lowest_free_slot(&leased(&[1, 3]), nz(4)), Some(nz(2)));
9503        // The ceiling is a refusal, never a reason to allocate past it.
9504        assert_eq!(lowest_free_slot(&leased(&[1]), nz(1)), None);
9505        assert_eq!(lowest_free_slot(&leased(&[1, 2]), nz(2)), None);
9506        // An ephemeral attempt holds no slot and cannot block one.
9507        let ephemeral = vec![RunnerAttempt::allocate(
9508            AttemptId::new_random(),
9509            fixtures::POLICY_ID,
9510            "/srv/rman/host/abc",
9511            fixtures::created_at(),
9512        )];
9513        assert_eq!(lowest_free_slot(&ephemeral, nz(1)), Some(nz(1)));
9514    }
9515
9516    #[test]
9517    fn a_slot_is_reusable_only_when_it_is_empty_or_holds_one_real_work_directory() {
9518        let root = tempfile::tempdir().unwrap();
9519        let slot = root.path().join("s1");
9520        fs::create_dir(&slot).unwrap();
9521        accept_reusable_slot(&slot).expect("an empty slot is reusable");
9522
9523        fs::create_dir(slot.join(DEFAULT_WORK_FOLDER)).unwrap();
9524        accept_reusable_slot(&slot).expect("a retained job workspace is reusable");
9525
9526        // Runner material a previous attempt left behind is refused rather than
9527        // reused or removed: deciding those bytes are safe is cleanup's job.
9528        fs::create_dir(slot.join("bin")).unwrap();
9529        fs::write(slot.join(".github-runner-id"), b"73").unwrap();
9530        let refusal = accept_reusable_slot(&slot).unwrap_err().to_string();
9531        assert!(refusal.contains("bin"), "{refusal}");
9532        assert!(refusal.contains(".github-runner-id"), "{refusal}");
9533
9534        // A `_work` that is not a real directory is not a job workspace.
9535        let file_work = root.path().join("s2");
9536        fs::create_dir(&file_work).unwrap();
9537        fs::write(file_work.join(DEFAULT_WORK_FOLDER), b"not a directory").unwrap();
9538        assert!(accept_reusable_slot(&file_work).is_err());
9539    }
9540
9541    #[cfg(unix)]
9542    #[test]
9543    fn a_link_shaped_work_directory_is_refused_rather_than_followed() {
9544        // Windows needs a privilege to create either kind of link, so the
9545        // link-shaped cases are asserted here; the rule itself is
9546        // platform-independent because it is `symlink_metadata`'s answer.
9547        let root = tempfile::tempdir().unwrap();
9548        let elsewhere = root.path().join("elsewhere");
9549        fs::create_dir(&elsewhere).unwrap();
9550
9551        let slot = root.path().join("s1");
9552        fs::create_dir(&slot).unwrap();
9553        std::os::unix::fs::symlink(&elsewhere, slot.join(DEFAULT_WORK_FOLDER)).unwrap();
9554        assert!(accept_reusable_slot(&slot).is_err());
9555
9556        let linked_slot = root.path().join("s2");
9557        std::os::unix::fs::symlink(&elsewhere, &linked_slot).unwrap();
9558        assert!(create_or_validate_slot(&linked_slot).is_err());
9559    }
9560
9561    #[test]
9562    fn a_slot_standing_where_a_file_is_refuses_rather_than_replacing_it() {
9563        let root = tempfile::tempdir().unwrap();
9564        let occupied = root.path().join("s1");
9565        fs::write(&occupied, b"an operator's file").unwrap();
9566        let refusal = create_or_validate_slot(&occupied).unwrap_err().to_string();
9567        assert!(refusal.contains("is not a directory"), "{refusal}");
9568        assert_eq!(fs::read_to_string(&occupied).unwrap(), "an operator's file");
9569
9570        let fresh = root.path().join("s2");
9571        create_or_validate_slot(&fresh).expect("a missing slot is created");
9572        assert!(fresh.is_dir());
9573        create_or_validate_slot(&fresh).expect("an existing directory is accepted");
9574    }
9575
9576    #[test]
9577    fn the_retained_work_directory_is_matched_the_way_the_filesystem_matches_it() {
9578        assert!(is_work_folder(OsStr::new(DEFAULT_WORK_FOLDER)));
9579        assert!(!is_work_folder(OsStr::new("_work2")));
9580        // A Windows filesystem is case-insensitive, so `_Work` *is* the retained
9581        // job workspace there and must never be removed as a leftover; on a
9582        // case-sensitive filesystem it is a different directory entirely.
9583        assert_eq!(is_work_folder(OsStr::new("_Work")), cfg!(windows));
9584    }
9585
9586    #[test]
9587    fn package_materialization_never_overwrites_or_follows_a_retained_work_directory() {
9588        let root = tempfile::tempdir().unwrap();
9589        let package = root.path().join("package");
9590        fs::create_dir_all(package.join("bin")).unwrap();
9591        fs::write(package.join("bin").join("Runner.Listener"), b"binary").unwrap();
9592        // A nested `_work` inside the package's own tree is an ordinary name.
9593        fs::create_dir_all(package.join("externals").join(DEFAULT_WORK_FOLDER)).unwrap();
9594
9595        let slot = root.path().join("s1");
9596        let retained = slot.join(DEFAULT_WORK_FOLDER).join("repo");
9597        fs::create_dir_all(&retained).unwrap();
9598        fs::write(retained.join("checkout.txt"), b"from the first job").unwrap();
9599
9600        copy_package_tree(&package, &slot, &TrustedTrees::default())
9601            .expect("the package lays out around `_work`");
9602        assert!(slot.join("bin").join("Runner.Listener").exists());
9603        assert!(
9604            slot.join("externals").join(DEFAULT_WORK_FOLDER).is_dir(),
9605            "the guard is top-level only"
9606        );
9607        assert_eq!(
9608            fs::read_to_string(retained.join("checkout.txt")).unwrap(),
9609            "from the first job"
9610        );
9611
9612        // A package that ever grew a top-level `_work` is refused, not merged.
9613        fs::create_dir(package.join(DEFAULT_WORK_FOLDER)).unwrap();
9614        let error = copy_package_tree(&package, &slot, &TrustedTrees::default()).unwrap_err();
9615        assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
9616        assert_eq!(
9617            fs::read_to_string(retained.join("checkout.txt")).unwrap(),
9618            "from the first job"
9619        );
9620    }
9621
9622    #[test]
9623    fn rolling_back_a_materialization_keeps_a_slot_but_removes_a_disposable_directory() {
9624        let root = tempfile::tempdir().unwrap();
9625
9626        let slot = root.path().join("s1");
9627        let retained = slot.join(DEFAULT_WORK_FOLDER);
9628        fs::create_dir_all(retained.join("repo")).unwrap();
9629        fs::write(retained.join("repo").join("checkout.txt"), b"kept").unwrap();
9630        fs::create_dir_all(slot.join("bin")).unwrap();
9631        fs::write(slot.join(".github-runner-id"), b"73").unwrap();
9632        let persistent = RunnerAttempt::allocate_in(
9633            AttemptId::new_random(),
9634            fixtures::POLICY_ID,
9635            &slot,
9636            AttemptWorkspace::persistent_slot(nz(1)),
9637            fixtures::created_at(),
9638        );
9639
9640        remove_materialized_package(&persistent).unwrap();
9641        assert!(slot.is_dir(), "the slot itself is not removed");
9642        assert!(!slot.join("bin").exists());
9643        assert!(!slot.join(".github-runner-id").exists());
9644        assert_eq!(
9645            fs::read_to_string(retained.join("repo").join("checkout.txt")).unwrap(),
9646            "kept"
9647        );
9648
9649        let disposable_path = root.path().join("abcdef012345");
9650        fs::create_dir_all(disposable_path.join(DEFAULT_WORK_FOLDER)).unwrap();
9651        let disposable = RunnerAttempt::allocate(
9652            AttemptId::new_random(),
9653            fixtures::POLICY_ID,
9654            &disposable_path,
9655            fixtures::created_at(),
9656        );
9657        remove_materialized_package(&disposable).unwrap();
9658        assert!(
9659            !disposable_path.exists(),
9660            "a disposable directory is still removed whole"
9661        );
9662    }
9663
9664    // -- c3: persistent cleanup and recovery --------------------------------
9665
9666    /// The runner state one attempt leaves at a slot root, as a real attempt
9667    /// leaves it: binaries, registration identity, a JIT handoff that outlived
9668    /// its process, and this agent's own lifecycle sidecars.
9669    ///
9670    /// Driven by [`SENSITIVE_SLOT_ENTRIES`] rather than by a second copy of it,
9671    /// so a name added to the thing cleanup must prove absent is a name every
9672    /// test here starts leaving behind.
9673    fn litter_the_slot(slot: &Path) {
9674        for directory in ["bin", "externals", "_diag"] {
9675            fs::create_dir_all(slot.join(directory)).unwrap();
9676        }
9677        fs::write(slot.join("bin").join("Runner.Listener"), b"binary").unwrap();
9678        for file in SENSITIVE_SLOT_ENTRIES
9679            .iter()
9680            .filter(|entry| !slot.join(entry).is_dir())
9681        {
9682            fs::write(slot.join(file), b"runner state").unwrap();
9683        }
9684        // A handoff whose owning process died before `Drop` could delete it.
9685        fs::write(
9686            slot.join(format!(
9687                "{}0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0.tmp",
9688                RestrictiveHandoff::NAME_PREFIX
9689            )),
9690            JIT.as_bytes(),
9691        )
9692        .unwrap();
9693    }
9694
9695    /// A marker under `_work` of the kind a job leaves for the next one.
9696    fn retain_under_work(slot: &Path) -> PathBuf {
9697        let checkout = slot.join(DEFAULT_WORK_FOLDER).join("repo").join("target");
9698        fs::create_dir_all(&checkout).unwrap();
9699        let marker = checkout.join("build-output.bin");
9700        fs::write(&marker, RETAINED).unwrap();
9701        marker
9702    }
9703
9704    /// What a job leaves under `_work` for the next job to reuse.
9705    const RETAINED: &str = "a Git-ignored build output the next job reuses";
9706
9707    /// Every direct entry of a directory, sorted, as plain strings.
9708    fn entries_of(directory: &Path) -> Vec<String> {
9709        let mut names: Vec<String> = fs::read_dir(directory)
9710            .unwrap()
9711            .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
9712            .collect();
9713        names.sort();
9714        names
9715    }
9716
9717    /// The one entry a cleaned slot is allowed to hold.
9718    fn only_the_job_workspace() -> Vec<String> {
9719        vec![DEFAULT_WORK_FOLDER.to_owned()]
9720    }
9721
9722    #[cfg(unix)]
9723    #[test]
9724    fn disposable_tree_removal_does_not_open_a_dotnet_diagnostic_fifo() {
9725        use std::sync::mpsc;
9726
9727        let temporary = tempfile::tempdir().unwrap();
9728        let tree = temporary.path().join("attempt");
9729        let diagnostic = tree.join("tmp/clr-debug-pipe-runner-in");
9730        fs::create_dir_all(diagnostic.parent().unwrap()).unwrap();
9731        assert!(
9732            std::process::Command::new("mkfifo")
9733                .arg(&diagnostic)
9734                .status()
9735                .unwrap()
9736                .success()
9737        );
9738
9739        let (finished, result) = mpsc::channel();
9740        std::thread::spawn(move || {
9741            let removed = remove_runtime_tree(&tree);
9742            let _ = finished.send(removed);
9743        });
9744
9745        result
9746            .recv_timeout(Duration::from_secs(2))
9747            .expect("runtime deletion must not wait for a FIFO peer")
9748            .unwrap();
9749        assert!(!diagnostic.exists());
9750    }
9751
9752    /// The junction WinINet leaves in a job's profile, reproduced: its own DACL
9753    /// denies `Everyone` the right to list it.
9754    #[cfg(windows)]
9755    #[test]
9756    fn disposable_tree_removal_unlinks_a_junction_nobody_may_list() {
9757        let temporary = tempfile::tempdir().unwrap();
9758        let tree = temporary.path().join("attempt");
9759        let cache = tree.join(r"tmp\home\AppData\Local\Microsoft\Windows\INetCache");
9760        let outside = temporary.path().join("outside");
9761        fs::create_dir_all(&cache).unwrap();
9762        fs::create_dir_all(&outside).unwrap();
9763        fs::write(outside.join("keep.txt"), b"not the attempt's").unwrap();
9764        fs::write(tree.join("tmp").join("job.log"), b"job output").unwrap();
9765        let junction = cache.join("Content.IE5");
9766        let created = std::process::Command::new("cmd")
9767            .arg("/C")
9768            .arg("mklink")
9769            .arg("/J")
9770            .arg(&junction)
9771            .arg(&outside)
9772            .output()
9773            .unwrap();
9774        assert!(created.status.success(), "{created:?}");
9775        // `/L` puts the entry on the junction itself, as WinINet does, rather
9776        // than on the directory it points at. `*S-1-1-0` is `Everyone` in every
9777        // display language.
9778        let denied = std::process::Command::new("icacls")
9779            .arg(&junction)
9780            .args(["/L", "/deny", "*S-1-1-0:(RD)"])
9781            .output()
9782            .unwrap();
9783        assert!(denied.status.success(), "{denied:?}");
9784
9785        // The control: without the fix the tree cannot be removed at all.
9786        let unfixed = remove_dir_all::remove_dir_all(&tree)
9787            .expect_err("the fixture no longer reproduces the WinINet junction");
9788        assert_eq!(unfixed.kind(), std::io::ErrorKind::PermissionDenied);
9789        assert!(junction.symlink_metadata().is_ok());
9790
9791        remove_runtime_tree(&tree).expect("a tree holding a deny-list junction is removed");
9792        assert!(!tree.exists(), "the attempt tree survived");
9793        assert_eq!(
9794            fs::read(outside.join("keep.txt")).unwrap(),
9795            b"not the attempt's",
9796            "removing the junction followed it into the directory it points at"
9797        );
9798    }
9799
9800    /// One slot entry that refuses to be removed, and the undo that lets the
9801    /// temporary directory be torn down afterwards.
9802    ///
9803    /// The two operating systems refuse for different reasons and there is no
9804    /// portable third. Windows will not open a file for deletion while a handle
9805    /// with share mode zero is held on it; Unix will not unlink from a directory
9806    /// the caller cannot write. Both are states a real machine reaches -- a
9807    /// scanner holding a file open, a job that left a directory read-only -- so
9808    /// the injection is a filesystem fact rather than a seam cut into the
9809    /// product for a test to pull.
9810    ///
9811    /// The Unix half is a permission, and permissions do not apply to `root`.
9812    /// [`Self::inject`] proves the block on a throwaway directory before
9813    /// claiming it, so a suite running as `root` says it could not inject rather
9814    /// than asserting nothing and passing.
9815    struct BlockedDeletion {
9816        directory: PathBuf,
9817        #[cfg(windows)]
9818        _handle: fs::File,
9819    }
9820
9821    impl BlockedDeletion {
9822        const HELD: &'static str = "held-open";
9823
9824        /// Fill `directory` with a file that cannot be removed, or answer `None`
9825        /// when this account cannot be stopped from removing anything.
9826        fn inject(directory: &Path) -> Option<Self> {
9827            #[cfg(unix)]
9828            if !Self::refusal_is_possible() {
9829                return None;
9830            }
9831            fs::create_dir_all(directory).unwrap();
9832            fs::write(
9833                directory.join(Self::HELD),
9834                b"a file the scrub cannot remove",
9835            )
9836            .unwrap();
9837            #[cfg(windows)]
9838            let handle = {
9839                use std::os::windows::fs::OpenOptionsExt;
9840
9841                fs::OpenOptions::new()
9842                    .read(true)
9843                    .share_mode(0)
9844                    .open(directory.join(Self::HELD))
9845                    .expect("the blocking handle opens")
9846            };
9847            #[cfg(unix)]
9848            Self::set_mode(directory, 0o555);
9849            Some(Self {
9850                directory: directory.to_path_buf(),
9851                #[cfg(windows)]
9852                _handle: handle,
9853            })
9854        }
9855
9856        fn release(self) {
9857            drop(self);
9858        }
9859
9860        #[cfg(unix)]
9861        fn refusal_is_possible() -> bool {
9862            let probe = tempfile::tempdir().unwrap();
9863            let directory = probe.path().join("probe");
9864            fs::create_dir(&directory).unwrap();
9865            fs::write(directory.join("file"), b"probe").unwrap();
9866            Self::set_mode(&directory, 0o555);
9867            let refused = fs::remove_dir_all(&directory).is_err();
9868            Self::set_mode(&directory, 0o755);
9869            refused
9870        }
9871
9872        #[cfg(unix)]
9873        fn set_mode(directory: &Path, mode: u32) {
9874            use std::os::unix::fs::PermissionsExt;
9875
9876            let mut permissions = fs::metadata(directory).unwrap().permissions();
9877            permissions.set_mode(mode);
9878            fs::set_permissions(directory, permissions).unwrap();
9879        }
9880    }
9881
9882    impl Drop for BlockedDeletion {
9883        fn drop(&mut self) {
9884            #[cfg(unix)]
9885            Self::set_mode(&self.directory, 0o755);
9886            #[cfg(not(unix))]
9887            let _ = &self.directory;
9888        }
9889    }
9890
9891    #[tokio::test]
9892    async fn two_sequential_jobs_keep_the_checkout_and_start_without_the_earlier_runner_state() {
9893        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
9894            .with_persistent_workspace(1);
9895        harness.ready().await;
9896
9897        let first = harness.launch().await;
9898        let slot = harness.slot_path(1);
9899        assert_eq!(first.runtime_path(), slot);
9900        assert_eq!(
9901            read_runner_id(&slot),
9902            Some(73),
9903            "the attempt registered, so its identity is on disk"
9904        );
9905        let marker = retain_under_work(&slot);
9906        litter_the_slot(&slot);
9907
9908        harness.cleanup_retaining_work(first.id).await;
9909
9910        // The allowlist is exactly one entry, so this assertion is the security
9911        // property in full: what is retained, and that nothing else is.
9912        assert_eq!(entries_of(&slot), only_the_job_workspace());
9913        assert_eq!(fs::read_to_string(&marker).unwrap(), RETAINED);
9914        assert_eq!(
9915            read_runner_id(&slot),
9916            None,
9917            "the first attempt's registration identity is gone before the second starts"
9918        );
9919        assert_eq!(harness.attempt(first.id).state(), AttemptState::Cleaned);
9920        assert!(!harness.attempt(first.id).holds_slot_lease());
9921
9922        let second = harness.launch().await;
9923        assert_ne!(second.id, first.id);
9924        assert_eq!(second.workspace(), AttemptWorkspace::persistent_slot(nz(1)));
9925        assert_eq!(
9926            second.runtime_path(),
9927            slot,
9928            "the same slot, so the same retained `_work`"
9929        );
9930        assert_eq!(fs::read_to_string(&marker).unwrap(), RETAINED);
9931    }
9932
9933    #[tokio::test]
9934    async fn cleaning_a_persistent_slot_needs_no_policy_and_scans_no_directory_for_ownership() {
9935        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
9936            .with_persistent_workspace(1);
9937        harness.ready().await;
9938        let attempt = harness.launch().await;
9939        let slot = harness.slot_path(1);
9940        let marker = retain_under_work(&slot);
9941        litter_the_slot(&slot);
9942        harness.conclude(attempt.id);
9943
9944        // A repository removed from the product between the attempt concluding
9945        // and the sweep reaching it. The journalled runtime path and slot are
9946        // the only facts left, and `04-security-recovery.md` requires them to be
9947        // enough: the alternative is scanning a root to work out which
9948        // directories were ours, which invariant 6 forbids.
9949        harness
9950            .store
9951            .remove_policy(harness.policy.id, harness.policy.revision())
9952            .unwrap();
9953        assert!(harness.store.policy(harness.policy.id).unwrap().is_none());
9954
9955        harness
9956            .launcher
9957            .clean(attempt.id)
9958            .await
9959            .expect("journal facts alone are enough to clean the slot");
9960
9961        assert_eq!(entries_of(&slot), only_the_job_workspace());
9962        assert!(marker.exists());
9963        assert_eq!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
9964    }
9965
9966    #[tokio::test]
9967    async fn an_injected_partial_deletion_quarantines_the_slot_across_a_restart() {
9968        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
9969            .with_persistent_workspace(2);
9970        harness.ready().await;
9971        let first = harness.launch().await;
9972        let slot = harness.slot_path(1);
9973        let marker = retain_under_work(&slot);
9974        litter_the_slot(&slot);
9975        harness.conclude(first.id);
9976
9977        let Some(block) = BlockedDeletion::inject(&slot.join("bin")) else {
9978            eprintln!(
9979                "skipped: this account cannot be refused a deletion, so no partial deletion can \
9980                 be injected"
9981            );
9982            return;
9983        };
9984
9985        let refusal = harness
9986            .launcher
9987            .clean(first.id)
9988            .await
9989            .expect_err("a deletion that failed may not report a cleaned slot");
9990        let rendered = refusal.reason.to_string();
9991        assert!(rendered.contains("could not be removed"), "{rendered}");
9992
9993        let held = harness.attempt(first.id);
9994        assert_eq!(held.state(), AttemptState::Failed, "still not cleaned");
9995        assert!(held.holds_slot_lease(), "so the slot is still leased");
9996        assert!(
9997            !held.state().counts_against_capacity(),
9998            "and a concluded attempt still costs the host no capacity"
9999        );
10000
10001        // The same journal and the same directories, under a launcher that
10002        // remembers nothing. Recovery must complete: a host that can launch
10003        // nothing at all because one slot is stuck is not what "does not count
10004        // as active host capacity" means.
10005        let restarted = harness.restart();
10006        restarted
10007            .recover_startup(std::slice::from_ref(&harness.policy))
10008            .await
10009            .expect("one quarantined slot does not stop the host recovering");
10010        assert_eq!(
10011            harness.attempt(first.id).state(),
10012            AttemptState::Failed,
10013            "the quarantine survived the restart"
10014        );
10015        assert!(
10016            harness
10017                .reconcile_events
10018                .events()
10019                .iter()
10020                .any(|event| matches!(
10021                    event,
10022                    LifecycleEvent::AttemptCleanFailed {
10023                        reason: "slot_entry_could_not_be_removed",
10024                        ..
10025                    }
10026                )),
10027            "the refusal is reported rather than retried in silence"
10028        );
10029
10030        // Capacity two, slot one quarantined: the next attempt goes to s2 and
10031        // never to the slot still holding runner state.
10032        let guard = harness.allocation_lock.acquire().await.unwrap();
10033        let second = restarted
10034            .launch(LaunchRequest {
10035                host: &harness.host,
10036                policy: &harness.policy,
10037                allocation_guard: &guard,
10038                lock_wait: Duration::ZERO,
10039            })
10040            .await
10041            .expect("the host can still launch");
10042        assert_eq!(second.workspace(), AttemptWorkspace::persistent_slot(nz(2)));
10043        drop(guard);
10044
10045        // And the same cleanup succeeds once the obstruction is gone, which is
10046        // what "retry through normal recovery" has to mean.
10047        block.release();
10048        restarted
10049            .clean(first.id)
10050            .await
10051            .expect("the retried cleanup completes");
10052        assert_eq!(entries_of(&slot), only_the_job_workspace());
10053        assert!(marker.exists());
10054        assert_eq!(harness.attempt(first.id).state(), AttemptState::Cleaned);
10055    }
10056
10057    /// The disposable half of the same injection, which had no test of its own.
10058    ///
10059    /// `scrub_workspace`'s ephemeral arm turns a failed `remove_dir_all` into
10060    /// `"attempt workspace could not be removed"`, and until now that branch was
10061    /// only reachable in theory: every injected-deletion test drove a persistent
10062    /// slot. The property is the same one and matters for the same reason --
10063    /// `04-security-recovery.md`'s "Cleanup partly fails and the slot is reused
10064    /// anyway" -- but the disposable guarantee is stronger, so a cleanup that
10065    /// reported success over a directory it had not removed would be the
10066    /// contamination gate failing silently rather than a slot being held.
10067    #[tokio::test]
10068    async fn an_injected_deletion_failure_leaves_a_disposable_attempt_uncleaned() {
10069        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
10070        harness.ready().await;
10071        let attempt = harness.launch().await;
10072        let runtime = attempt.runtime_path().to_path_buf();
10073        assert_eq!(attempt.workspace(), AttemptWorkspace::Ephemeral);
10074        harness.conclude(attempt.id);
10075
10076        let Some(block) = BlockedDeletion::inject(&runtime.join("held-open-subdirectory")) else {
10077            eprintln!(
10078                "skipped: this account cannot be refused a deletion, so no partial deletion can be injected"
10079            );
10080            return;
10081        };
10082
10083        let refusal = harness
10084            .launcher
10085            .clean(attempt.id)
10086            .await
10087            .expect_err("a deletion that failed may not report a removed workspace");
10088        let rendered = refusal.reason.to_string();
10089        assert!(
10090            rendered.contains("could not be removed"),
10091            "the refusal names what happened: {rendered}"
10092        );
10093        assert_ne!(
10094            harness.attempt(attempt.id).state(),
10095            AttemptState::Cleaned,
10096            "an attempt whose directory is still on disk is not cleaned"
10097        );
10098        assert!(
10099            runtime.is_dir(),
10100            "the directory the removal could not finish is still there, which is the fact the journal must keep agreeing with"
10101        );
10102
10103        let restarted = harness.restart();
10104        restarted
10105            .recover_startup(std::slice::from_ref(&harness.policy))
10106            .await
10107            .expect("one locked ephemeral workspace does not stop startup recovery");
10108        assert!(
10109            harness
10110                .reconcile_events
10111                .events()
10112                .iter()
10113                .any(|event| matches!(
10114                    event,
10115                    LifecycleEvent::AttemptCleanFailed {
10116                        attempt: failed_attempt,
10117                        reason: "ephemeral_workspace_could_not_be_removed",
10118                        ..
10119                    } if *failed_attempt == attempt.id
10120                )),
10121            "the deferred cleanup remains visible while the daemon keeps running"
10122        );
10123
10124        // And the ordinary retry -- the reconciler's next terminal sweep --
10125        // finishes it once the obstruction is gone.
10126        block.release();
10127        harness
10128            .launcher
10129            .clean(attempt.id)
10130            .await
10131            .expect("the retried cleanup completes");
10132        assert!(!runtime.exists(), "the whole attempt directory goes");
10133        assert_eq!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
10134    }
10135
10136    /// Supervision retries a workspace it cannot remove with per-attempt
10137    /// backoff, so a stuck workspace is reported once per delay rather than on
10138    /// every pass.
10139    #[tokio::test]
10140    async fn supervision_backs_off_a_workspace_it_cannot_remove() {
10141        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
10142        harness.ready().await;
10143        let attempt = harness.launch().await;
10144        let runtime = attempt.runtime_path().to_path_buf();
10145        harness.conclude(attempt.id);
10146        let Some(block) = BlockedDeletion::inject(&runtime.join("held-open-subdirectory")) else {
10147            eprintln!(
10148                "skipped: this account cannot be refused a deletion, so no partial deletion can be injected"
10149            );
10150            return;
10151        };
10152        let failures = || {
10153            harness
10154                .reconcile_events
10155                .events()
10156                .iter()
10157                .filter(|event| {
10158                    matches!(
10159                        event,
10160                        LifecycleEvent::AttemptCleanFailed { attempt: failed, .. }
10161                            if *failed == attempt.id
10162                    )
10163                })
10164                .count()
10165        };
10166        let supervise = async || {
10167            harness
10168                .launcher
10169                .supervise(&harness.policy)
10170                .await
10171                .expect("a stuck workspace does not fail supervision");
10172        };
10173
10174        supervise().await;
10175        assert_eq!(failures(), 1);
10176        supervise().await;
10177        assert_eq!(
10178            failures(),
10179            1,
10180            "the failed cleanup was retried on the very next pass"
10181        );
10182        harness.clock.advance_secs(31);
10183        supervise().await;
10184        assert_eq!(
10185            failures(),
10186            2,
10187            "the cleanup was not retried once its delay passed"
10188        );
10189
10190        block.release();
10191        harness.clock.advance_secs(61);
10192        supervise().await;
10193        assert!(
10194            !runtime.exists(),
10195            "the retry removes the workspace once it can"
10196        );
10197        assert_eq!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
10198    }
10199
10200    #[tokio::test]
10201    async fn changing_a_repository_back_to_ephemeral_leaves_every_old_slot_untouched() {
10202        let mut harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
10203            .with_persistent_workspace(1);
10204        harness.ready().await;
10205        let first = harness.launch().await;
10206        let slot = harness.slot_path(1);
10207        let marker = retain_under_work(&slot);
10208        harness.cleanup_retaining_work(first.id).await;
10209
10210        // Every attempt for this policy is cleaned, so the mutation is allowed
10211        // (`04-security-recovery.md`, "Recovery rules"). What it must not do is
10212        // move or delete anything the operator still owns.
10213        harness
10214            .policy
10215            .set_workspace_policy(WorkspacePolicy::Ephemeral)
10216            .unwrap();
10217
10218        let second = harness.launch().await;
10219        assert_eq!(second.workspace(), AttemptWorkspace::Ephemeral);
10220        assert_eq!(
10221            second.runtime_path().parent().unwrap(),
10222            harness.host_root(),
10223            "a disposable attempt is a child of the host root"
10224        );
10225        assert!(slot.is_dir(), "the old slot is left where it stands");
10226        assert_eq!(fs::read_to_string(&marker).unwrap(), RETAINED);
10227
10228        // And cleaning the disposable attempt removes its own directory whole
10229        // without reaching the retained slot beside it.
10230        harness.conclude(second.id);
10231        harness.launcher.clean(second.id).await.unwrap();
10232        assert!(!second.runtime_path().exists());
10233        assert!(marker.exists());
10234    }
10235
10236    #[tokio::test]
10237    async fn a_persistent_slot_is_scrubbed_only_after_the_process_is_signalled_and_gone() {
10238        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
10239            .with_persistent_workspace(1);
10240        harness.ready().await;
10241        let slot = harness.slot_path(1);
10242        fs::create_dir_all(&slot).unwrap();
10243        let marker = retain_under_work(&slot);
10244        litter_the_slot(&slot);
10245
10246        let id = AttemptId::new_random();
10247        let mut attempt = RunnerAttempt::allocate_in(
10248            id,
10249            harness.policy.id,
10250            &slot,
10251            AttemptWorkspace::persistent_slot(nz(1)),
10252            harness.clock.now(),
10253        );
10254        attempt.jit_received(harness.clock.now()).unwrap();
10255        attempt.started(4242, harness.clock.now()).unwrap();
10256        harness.store.record_attempt(&attempt).unwrap();
10257        harness.clock.advance_secs(11);
10258        harness.processes.set_alive(true);
10259        harness
10260            .github
10261            .observe(GithubRunnerObservation::NotRegistered);
10262
10263        harness.launcher.supervise(&harness.policy).await.unwrap();
10264
10265        // The identity and termination ordering `e3` established is unchanged by
10266        // the workspace kind: the intent is durable before the signal, and the
10267        // slot is scrubbed only once the process is gone.
10268        let actions = harness.processes.actions.lock().unwrap().clone();
10269        let intent = actions
10270            .iter()
10271            .position(|action| *action == "terminate_intent")
10272            .unwrap();
10273        let signal = actions
10274            .iter()
10275            .position(|action| *action == "terminate")
10276            .unwrap();
10277        assert!(intent < signal, "{actions:?}");
10278        assert!(!harness.processes.alive.load(Ordering::SeqCst));
10279
10280        let cleaned = harness.attempt(id);
10281        assert_eq!(cleaned.state(), AttemptState::Cleaned);
10282        assert!(matches!(
10283            cleaned.outcome(),
10284            Some(AttemptOutcome::Failed {
10285                reason: FailureReason::TerminatedAfterRegistrationTimeout
10286            })
10287        ));
10288        assert_eq!(entries_of(&slot), only_the_job_workspace());
10289        assert!(marker.exists());
10290    }
10291
10292    #[test]
10293    fn a_scrub_retains_one_real_work_directory_and_removes_every_other_entry() {
10294        let root = tempfile::tempdir().unwrap();
10295        let slot = root.path().join("s1");
10296        fs::create_dir(&slot).unwrap();
10297        let marker = retain_under_work(&slot);
10298        litter_the_slot(&slot);
10299        fs::write(slot.join("runner-package"), b"verified").unwrap();
10300
10301        scrub_slot_entries(&slot).expect("a slot of ordinary runner state scrubs");
10302        verify_slot_scrubbed(&slot).expect("and proves it afterwards");
10303
10304        assert_eq!(entries_of(&slot), only_the_job_workspace());
10305        assert!(marker.exists());
10306    }
10307
10308    #[test]
10309    fn a_residue_refusal_never_reports_the_under_count_as_the_fact() {
10310        let slot = Path::new("/runners/s1");
10311
10312        // The ordinary case: the listing counted, so the count is the fact and
10313        // the published names qualify it.
10314        let counted = residue_detail(slot, 2, &["`bin`".to_owned()]);
10315        assert!(counted.contains("2 entries other than"), "{counted}");
10316        assert!(counted.contains("including `bin`"), "{counted}");
10317        assert_eq!(
10318            residue_detail(slot, 1, &[]),
10319            format!(
10320                "1 entry other than `{DEFAULT_WORK_FOLDER}` survived cleanup of {}",
10321                slot.display()
10322            )
10323        );
10324
10325        // The race the second pass exists for: the listing saw nothing and the
10326        // filesystem answered otherwise. Saying "0 entries survived" here would
10327        // state the under-count as the fact and contradict the rest of the
10328        // sentence.
10329        let raced = residue_detail(slot, 0, &["`.credentials`".to_owned()]);
10330        assert!(!raced.contains('0'), "{raced}");
10331        assert!(raced.contains("reported nothing but"), "{raced}");
10332        assert!(raced.contains("`.credentials` survived cleanup"), "{raced}");
10333    }
10334
10335    #[test]
10336    fn verification_asks_the_filesystem_rather_than_the_listing_that_missed_an_entry() {
10337        let root = tempfile::tempdir().unwrap();
10338        let slot = root.path().join("s1");
10339        fs::create_dir(&slot).unwrap();
10340        fs::create_dir(slot.join(DEFAULT_WORK_FOLDER)).unwrap();
10341        verify_slot_scrubbed(&slot).expect("only `_work` is a clean slot");
10342
10343        // Runner binaries, registration identity, process identity and this
10344        // agent's lifecycle marks, one at a time, so a scrub that skipped
10345        // exactly one is still caught.
10346        for survivor in ["bin", ".credentials", IDENTITY_FILE, RUNNER_ID_FILE] {
10347            fs::write(slot.join(survivor), b"left behind").unwrap();
10348            let quarantine = verify_slot_scrubbed(&slot).unwrap_err();
10349            assert_eq!(quarantine.refusal, SlotRefusal::Residue);
10350            assert!(
10351                quarantine.detail.contains(&format!("`{survivor}`")),
10352                "{quarantine}"
10353            );
10354            fs::remove_file(slot.join(survivor)).unwrap();
10355        }
10356
10357        // A handoff is named by its published prefix, never by the UUID that
10358        // follows it, and never by the payload it holds.
10359        let handoff = slot.join(format!("{}whatever.tmp", RestrictiveHandoff::NAME_PREFIX));
10360        fs::write(&handoff, JIT.as_bytes()).unwrap();
10361        let quarantine = verify_slot_scrubbed(&slot).unwrap_err();
10362        assert!(
10363            quarantine.detail.contains("an encoded JIT handoff"),
10364            "{quarantine}"
10365        );
10366        assert!(!quarantine.detail.contains(JIT), "{quarantine}");
10367        fs::remove_file(&handoff).unwrap();
10368
10369        // A name a workflow chose is counted and never echoed: a slot root is
10370        // writable by the job, so a file named after a secret would be published
10371        // by any message that repeated the listing.
10372        fs::write(slot.join("ghp_DO_NOT_LEAK"), b"named by the job").unwrap();
10373        let quarantine = verify_slot_scrubbed(&slot).unwrap_err();
10374        assert!(
10375            quarantine.detail.contains("1 entry other than"),
10376            "{quarantine}"
10377        );
10378        assert!(
10379            !quarantine.detail.contains("ghp_DO_NOT_LEAK"),
10380            "{quarantine}"
10381        );
10382    }
10383
10384    #[test]
10385    fn a_slot_is_derived_from_the_journal_and_refused_when_it_disagrees() {
10386        let root = tempfile::tempdir().unwrap();
10387        let configured =
10388            LocalAbsolutePath::new(root.path().to_str().unwrap()).expect("a local absolute root");
10389        let slot = configured.as_path().join("s1");
10390        fs::create_dir(&slot).unwrap();
10391
10392        verify_journalled_slot(&slot, nz(1), Some(&configured))
10393            .expect("the journalled slot agrees");
10394        verify_journalled_slot(&slot, nz(1), None)
10395            .expect("and a policy that is gone removes a check, not the ability to clean");
10396
10397        // The journalled slot number is what names the directory. `s1` recorded
10398        // as slot two is corrupt state, not a slot to clean.
10399        assert_eq!(
10400            verify_journalled_slot(&slot, nz(2), None)
10401                .unwrap_err()
10402                .refusal,
10403            SlotRefusal::NotTheJournalledSlot
10404        );
10405        for stray in ["s1/nested", "not-a-slot", "s01"] {
10406            let path = configured.as_path().join(stray);
10407            assert_eq!(
10408                verify_journalled_slot(&path, nz(1), None)
10409                    .unwrap_err()
10410                    .refusal,
10411                SlotRefusal::NotTheJournalledSlot,
10412                "{}",
10413                path.display()
10414            );
10415        }
10416
10417        // A surviving policy that names a different root does not get to have
10418        // its disagreement resolved by deleting something.
10419        let elsewhere = tempfile::tempdir().unwrap();
10420        let other =
10421            LocalAbsolutePath::new(elsewhere.path().to_str().unwrap()).expect("a second root");
10422        assert_eq!(
10423            verify_journalled_slot(&slot, nz(1), Some(&other))
10424                .unwrap_err()
10425                .refusal,
10426            SlotRefusal::PolicyRootDisagrees
10427        );
10428    }
10429
10430    #[cfg(unix)]
10431    #[test]
10432    fn a_substituted_work_directory_quarantines_the_slot_and_deletes_nothing_outside_it() {
10433        // Windows needs a privilege to create a junction or a symlink, so the
10434        // substitution is made here; the rule is platform-independent because it
10435        // is `symlink_metadata`'s answer plus the reparse attribute.
10436        let root = tempfile::tempdir().unwrap();
10437        let outside = root.path().join("operator-data");
10438        fs::create_dir(&outside).unwrap();
10439        let sentinel = outside.join("do-not-delete.txt");
10440        fs::write(
10441            &sentinel,
10442            b"an operator's data, outside every approved root",
10443        )
10444        .unwrap();
10445
10446        let slot = root.path().join("s1");
10447        fs::create_dir(&slot).unwrap();
10448        fs::create_dir(slot.join("bin")).unwrap();
10449        std::os::unix::fs::symlink(&outside, slot.join(DEFAULT_WORK_FOLDER)).unwrap();
10450
10451        let quarantine = scrub_slot_entries(&slot).unwrap_err();
10452        assert_eq!(quarantine.refusal, SlotRefusal::WorkNotADirectory);
10453        assert!(
10454            sentinel.exists(),
10455            "the deletion followed the link out of the slot"
10456        );
10457        assert!(outside.is_dir());
10458        assert!(
10459            slot.join(DEFAULT_WORK_FOLDER).symlink_metadata().is_ok(),
10460            "the substituted link is left for the operator, never unlinked as if it were ours"
10461        );
10462
10463        // A `_work` that is a plain file is the same refusal for the same
10464        // reason: it is not a job workspace, and this is not the code that
10465        // decides what to do about it.
10466        let file_work = root.path().join("s2");
10467        fs::create_dir(&file_work).unwrap();
10468        fs::write(file_work.join(DEFAULT_WORK_FOLDER), b"not a directory").unwrap();
10469        assert_eq!(
10470            scrub_slot_entries(&file_work).unwrap_err().refusal,
10471            SlotRefusal::WorkNotADirectory
10472        );
10473    }
10474
10475    #[cfg(unix)]
10476    #[test]
10477    fn a_slot_replaced_by_a_link_out_of_its_root_is_refused_before_anything_is_read() {
10478        let root = tempfile::tempdir().unwrap();
10479        let outside = root.path().join("operator-data");
10480        fs::create_dir(&outside).unwrap();
10481        let sentinel = outside.join("do-not-delete.txt");
10482        fs::write(
10483            &sentinel,
10484            b"an operator's data, outside every approved root",
10485        )
10486        .unwrap();
10487
10488        // The lexical half of containment passes -- the name is right and the
10489        // parent is right -- and canonical resolution is what catches it.
10490        let inside = root.path().join("inside");
10491        fs::create_dir(&inside).unwrap();
10492        let slot = inside.join("s1");
10493        std::os::unix::fs::symlink(&outside, &slot).unwrap();
10494
10495        assert_eq!(
10496            verify_journalled_slot(&slot, nz(1), None)
10497                .unwrap_err()
10498                .refusal,
10499            SlotRefusal::Containment
10500        );
10501        assert!(sentinel.exists());
10502        assert!(
10503            slot.symlink_metadata().is_ok(),
10504            "the link is left for the operator rather than removed as if it were ours"
10505        );
10506    }
10507
10508    /// The Windows half of the case above.
10509    ///
10510    /// Containment was proven only on Unix, and a symbolic link is the wrong
10511    /// instrument to prove it with on Windows: creating one needs a privilege an
10512    /// ordinary workflow does not have, so it is not the substitution an
10513    /// attacker would reach for. A **junction** needs none, which makes it both
10514    /// the realistic attack and the one this repository must refuse -- and
10515    /// `04-security-recovery.md` names it in the same breath as the symlink for
10516    /// exactly that reason.
10517    #[cfg(windows)]
10518    #[test]
10519    fn a_slot_root_replaced_by_a_junction_is_refused_before_anything_is_read() {
10520        let root = tempfile::tempdir().unwrap();
10521        let outside = root.path().join("operator-data");
10522        fs::create_dir(&outside).unwrap();
10523        let sentinel = outside.join("do-not-delete.txt");
10524        fs::write(
10525            &sentinel,
10526            b"an operator's data, outside every approved root",
10527        )
10528        .unwrap();
10529
10530        // The lexical half of containment passes -- `s1` under the root the
10531        // journal names -- and canonical resolution is what catches it.
10532        let inside = root.path().join("inside");
10533        fs::create_dir(&inside).unwrap();
10534        let slot = inside.join("s1");
10535        let Some(()) = plant_junction(&slot, &outside) else {
10536            eprintln!("skipped: this machine would not create a directory junction");
10537            return;
10538        };
10539
10540        assert_eq!(
10541            verify_journalled_slot(&slot, nz(1), None)
10542                .unwrap_err()
10543                .refusal,
10544            SlotRefusal::Containment
10545        );
10546        assert!(
10547            sentinel.exists(),
10548            "the refusal resolved the junction and reached the operator's data"
10549        );
10550        assert!(
10551            slot.symlink_metadata().is_ok(),
10552            "the junction is left for the operator rather than removed as if it were ours"
10553        );
10554    }
10555
10556    /// Plant a directory junction at `link` pointing at `target`.
10557    ///
10558    /// A junction is the Windows substitution this has to refuse, and unlike a
10559    /// symbolic link it needs no privilege — which is exactly why it is the one
10560    /// an unprivileged workflow would reach for. `mklink` is a `cmd` builtin, so
10561    /// there is no binary to find and nothing to install; `None` means this
10562    /// machine would not make one and the caller says so rather than asserting
10563    /// nothing.
10564    #[cfg(windows)]
10565    fn plant_junction(link: &Path, target: &Path) -> Option<()> {
10566        let made = std::process::Command::new("cmd")
10567            .arg("/C")
10568            .arg("mklink")
10569            .arg("/J")
10570            .arg(link)
10571            .arg(target)
10572            .output()
10573            .ok()?;
10574        (made.status.success() && link.symlink_metadata().is_ok()).then_some(())
10575    }
10576
10577    /// The dependency caches live beside attempts, under `<runner root>/_cache`,
10578    /// and jobs link into them (pnpm hard-links from its store; a workflow may
10579    /// symlink or junction a cache into its workspace). Removing an ephemeral
10580    /// attempt must take the links and never what they point at.
10581    #[test]
10582    fn removing_an_attempt_never_reaches_into_the_dependency_caches_it_linked() {
10583        let root = tempfile::tempdir().unwrap();
10584        let cache = root.path().join("_cache").join("o").join("r").join("npm");
10585        fs::create_dir_all(&cache).unwrap();
10586        let cached = cache.join("package.tgz");
10587        fs::write(&cached, b"kept between jobs").unwrap();
10588        let attempt = root.path().join("0123456789abcdef");
10589        let work = attempt.join("_work").join("repo");
10590        fs::create_dir_all(&work).unwrap();
10591        fs::hard_link(&cached, work.join("hard-linked.tgz")).unwrap();
10592        #[cfg(unix)]
10593        std::os::unix::fs::symlink(&cache, work.join("linked-cache")).unwrap();
10594        #[cfg(windows)]
10595        if plant_junction(&work.join("linked-cache"), &cache).is_none() {
10596            eprintln!("this machine would not create a junction; the hard link is still checked");
10597        }
10598
10599        remove_runtime_tree(&attempt).unwrap();
10600        assert!(!attempt.exists());
10601        assert_eq!(fs::read(&cached).unwrap(), b"kept between jobs");
10602    }
10603
10604    #[cfg(windows)]
10605    #[test]
10606    fn a_work_directory_replaced_by_a_junction_fails_closed_and_deletes_nothing_beyond_it() {
10607        let root = tempfile::tempdir().unwrap();
10608        let outside = root.path().join("operator-data");
10609        fs::create_dir(&outside).unwrap();
10610        let sentinel = outside.join("do-not-delete.txt");
10611        fs::write(
10612            &sentinel,
10613            b"an operator's data, outside every approved root",
10614        )
10615        .unwrap();
10616
10617        let slot = root.path().join("s1");
10618        fs::create_dir(&slot).unwrap();
10619        fs::create_dir(slot.join("bin")).unwrap();
10620        let Some(()) = plant_junction(&slot.join(DEFAULT_WORK_FOLDER), &outside) else {
10621            eprintln!("skipped: this machine would not create a directory junction");
10622            return;
10623        };
10624
10625        // The reparse point is what `is_link_like` answers on, so a junction is
10626        // refused for the same reason a symbolic link is and neither is
10627        // descended into.
10628        let work = fs::symlink_metadata(slot.join(DEFAULT_WORK_FOLDER)).unwrap();
10629        assert!(is_link_like(&work), "a junction is a reparse point");
10630        let quarantine = scrub_slot_entries(&slot).unwrap_err();
10631        assert_eq!(quarantine.refusal, SlotRefusal::WorkNotADirectory);
10632        assert!(
10633            sentinel.exists(),
10634            "the deletion followed the junction out of the slot"
10635        );
10636        assert!(outside.is_dir());
10637
10638        // A junction standing where an ordinary entry was is unlinked rather
10639        // than followed, so the removal still cannot reach through it.
10640        let elsewhere = root.path().join("s2");
10641        fs::create_dir(&elsewhere).unwrap();
10642        fs::create_dir(elsewhere.join(DEFAULT_WORK_FOLDER)).unwrap();
10643        if plant_junction(&elsewhere.join("externals"), &outside).is_some() {
10644            scrub_slot_entries(&elsewhere).expect("an ordinary entry is removed, junction or not");
10645            verify_slot_scrubbed(&elsewhere).expect("and the slot verifies");
10646            assert!(sentinel.exists(), "the junction was followed, not unlinked");
10647            assert_eq!(entries_of(&elsewhere), only_the_job_workspace());
10648        }
10649    }
10650
10651    #[cfg(unix)]
10652    #[tokio::test]
10653    async fn a_substituted_work_directory_leaves_the_attempt_uncleaned_and_still_leased() {
10654        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
10655            .with_persistent_workspace(2);
10656        harness.ready().await;
10657        let first = harness.launch().await;
10658        let slot = harness.slot_path(1);
10659        harness.conclude(first.id);
10660
10661        let outside = harness._root.path().join("operator-data");
10662        fs::create_dir_all(&outside).unwrap();
10663        let sentinel = outside.join("do-not-delete.txt");
10664        fs::write(&sentinel, b"outside every approved root").unwrap();
10665        std::os::unix::fs::symlink(&outside, slot.join(DEFAULT_WORK_FOLDER)).unwrap();
10666
10667        harness
10668            .launcher
10669            .clean(first.id)
10670            .await
10671            .expect_err("a slot whose `_work` was substituted is quarantined");
10672        assert!(sentinel.exists());
10673
10674        let held = harness.attempt(first.id);
10675        assert_eq!(held.state(), AttemptState::Failed);
10676        assert!(held.holds_slot_lease());
10677
10678        // The quarantined slot is not silently chosen again.
10679        let second = harness.launch().await;
10680        assert_eq!(second.workspace(), AttemptWorkspace::persistent_slot(nz(2)));
10681    }
10682
10683    #[test]
10684    fn a_slot_that_is_a_file_is_refused_and_a_slot_that_is_gone_is_not() {
10685        let root = tempfile::tempdir().unwrap();
10686        let occupied = root.path().join("s1");
10687        fs::write(&occupied, b"an operator's file").unwrap();
10688        // The journal check passes -- it is the right name under the right root
10689        // -- and the shape check is what refuses.
10690        verify_journalled_slot(&occupied, nz(1), None).expect("the path is the journalled slot");
10691        assert_eq!(
10692            slot_is_present(&occupied).unwrap_err().refusal,
10693            SlotRefusal::SlotNotADirectory
10694        );
10695        assert_eq!(fs::read_to_string(&occupied).unwrap(), "an operator's file");
10696
10697        // A directory that is simply not there leaves nothing to remove and
10698        // nothing to prove absent, so it is not a refusal.
10699        assert!(!slot_is_present(&root.path().join("s2")).unwrap());
10700        let present = root.path().join("s3");
10701        fs::create_dir(&present).unwrap();
10702        assert!(slot_is_present(&present).unwrap());
10703    }
10704
10705    #[test]
10706    fn cleanup_dispatches_on_the_journalled_kind_and_not_on_what_the_directory_holds() {
10707        let root = tempfile::tempdir().unwrap();
10708
10709        // A disposable directory that happens to contain a `_work` still goes
10710        // whole: the workspace kind is immutable so that the shape of a
10711        // directory a workflow can write to cannot choose its own algorithm.
10712        let disposable = root.path().join("abcdef012345");
10713        fs::create_dir_all(disposable.join(DEFAULT_WORK_FOLDER).join("repo")).unwrap();
10714        let ephemeral = RunnerAttempt::allocate(
10715            AttemptId::new_random(),
10716            fixtures::POLICY_ID,
10717            &disposable,
10718            fixtures::created_at(),
10719        );
10720        remove_materialized_package(&ephemeral).unwrap();
10721        assert!(!disposable.exists());
10722
10723        // And a slot keeps its `_work` with the same contents beneath it.
10724        let slot = root.path().join("s1");
10725        fs::create_dir_all(slot.join(DEFAULT_WORK_FOLDER).join("repo")).unwrap();
10726        fs::create_dir_all(slot.join("bin")).unwrap();
10727        let persistent = RunnerAttempt::allocate_in(
10728            AttemptId::new_random(),
10729            fixtures::POLICY_ID,
10730            &slot,
10731            AttemptWorkspace::persistent_slot(nz(1)),
10732            fixtures::created_at(),
10733        );
10734        remove_materialized_package(&persistent).unwrap();
10735        assert_eq!(entries_of(&slot), only_the_job_workspace());
10736        assert!(slot.join(DEFAULT_WORK_FOLDER).join("repo").is_dir());
10737    }
10738
10739    #[test]
10740    fn every_slot_refusal_names_a_distinct_event_class_and_keeps_the_lease() {
10741        let refusals = [
10742            SlotRefusal::NotTheJournalledSlot,
10743            SlotRefusal::PolicyRootDisagrees,
10744            SlotRefusal::Containment,
10745            SlotRefusal::SlotNotADirectory,
10746            SlotRefusal::Enumeration,
10747            SlotRefusal::WorkNotADirectory,
10748            SlotRefusal::Deletion,
10749            SlotRefusal::Residue,
10750        ];
10751        let classes: BTreeSet<&str> = refusals.iter().map(|refusal| refusal.class()).collect();
10752        assert_eq!(
10753            classes.len(),
10754            refusals.len(),
10755            "an event class shared by two refusals tells an operator less than it appears to"
10756        );
10757        for refusal in refusals {
10758            // The event field is a closed vocabulary, so it has to look like
10759            // one: `d1`'s sink allows the name verbatim.
10760            assert!(
10761                refusal
10762                    .class()
10763                    .chars()
10764                    .all(|c| c.is_ascii_lowercase() || c == '_'),
10765                "{}",
10766                refusal.class()
10767            );
10768            assert!(
10769                refusal.remediation().contains("slot lease"),
10770                "every refusal has to say the lease is still held: {}",
10771                refusal.class()
10772            );
10773        }
10774    }
10775
10776    #[test]
10777    fn copy_package_tree_copies_files_and_preserves_paths_with_spaces() {
10778        let root = tempfile::tempdir().unwrap();
10779        let source = root.path().join("source with spaces");
10780        let dest = root.path().join("dest with spaces");
10781
10782        fs::create_dir_all(&source).unwrap();
10783        fs::write(source.join("file1.txt"), b"hello").unwrap();
10784
10785        let nested = source.join("nested dir");
10786        fs::create_dir_all(&nested).unwrap();
10787        fs::write(nested.join("file2.txt"), b"world").unwrap();
10788
10789        // This is not a top-level `_work`, so it should be allowed
10790        let nested_work = nested.join(DEFAULT_WORK_FOLDER);
10791        fs::create_dir_all(&nested_work).unwrap();
10792        fs::write(nested_work.join("allowed.txt"), b"allowed").unwrap();
10793
10794        copy_package_tree(&source, &dest, &TrustedTrees::default()).unwrap();
10795
10796        assert_eq!(fs::read_to_string(dest.join("file1.txt")).unwrap(), "hello");
10797        assert_eq!(
10798            fs::read_to_string(dest.join("nested dir").join("file2.txt")).unwrap(),
10799            "world"
10800        );
10801        assert_eq!(
10802            fs::read_to_string(
10803                dest.join("nested dir")
10804                    .join(DEFAULT_WORK_FOLDER)
10805                    .join("allowed.txt")
10806            )
10807            .unwrap(),
10808            "allowed"
10809        );
10810    }
10811
10812    #[test]
10813    fn every_field_name_the_attempt_sink_emits_is_allowed() {
10814        use runner_manager_platform::logging::is_field_allowed;
10815
10816        // Kept beside the sink rather than derived from it, as in `reconcile`.
10817        for field in [
10818            "event",
10819            "attempt_id",
10820            "reason",
10821            "retry_in_ms",
10822            "duration_ms",
10823            "lock_wait_ms",
10824            "materialize_ms",
10825            "register_ms",
10826            "spawn_ms",
10827        ] {
10828            assert!(
10829                is_field_allowed(field),
10830                "`{field}` would be logged as `[redacted]`"
10831            );
10832        }
10833    }
10834
10835    #[cfg(unix)]
10836    fn package_entry(root: &Path, version: &str, manifest: &str) -> PathBuf {
10837        let entry = root.join("packages").join(version);
10838        fs::create_dir_all(entry.join("bin")).unwrap();
10839        fs::write(entry.join(crate::package::MANIFEST_FILE), manifest).unwrap();
10840        fs::write(entry.join("bin").join("Runner.Listener"), version).unwrap();
10841        entry
10842    }
10843
10844    /// The seed's binary's inode: a rebuilt seed holds a new file.
10845    #[cfg(unix)]
10846    fn listener_inode(seed: &Path) -> u64 {
10847        use std::os::unix::fs::MetadataExt as _;
10848        fs::metadata(seed.join("bin").join("Runner.Listener"))
10849            .unwrap()
10850            .ino()
10851    }
10852
10853    #[cfg(unix)]
10854    #[test]
10855    fn a_package_seed_is_built_once_and_reused_while_untouched() {
10856        let root = tempfile::tempdir().unwrap();
10857        let source = package_entry(root.path(), "2.336.0", "{\"digest\":\"a\"}");
10858        let seeds = root.path().join("runners").join(PACKAGE_SEED_DIR);
10859        let trusted = TrustedTrees::default();
10860
10861        let seed = refresh_package_seed(&source, &seeds, &trusted).unwrap();
10862        assert_eq!(seed, seeds.join("2.336.0"));
10863        assert_eq!(
10864            fs::read_to_string(seed.join("bin").join("Runner.Listener")).unwrap(),
10865            "2.336.0"
10866        );
10867
10868        let built = listener_inode(&seed);
10869        assert_eq!(
10870            refresh_package_seed(&source, &seeds, &trusted).unwrap(),
10871            seed
10872        );
10873        assert_eq!(listener_inode(&seed), built, "an untouched seed is reused");
10874    }
10875
10876    #[cfg(unix)]
10877    #[test]
10878    fn a_tampered_or_unrecorded_seed_is_rebuilt() {
10879        let root = tempfile::tempdir().unwrap();
10880        let source = package_entry(root.path(), "2.336.0", "{\"digest\":\"a\"}");
10881        let seeds = root.path().join("runners").join(PACKAGE_SEED_DIR);
10882        let trusted = TrustedTrees::default();
10883        let seed = refresh_package_seed(&source, &seeds, &trusted).unwrap();
10884
10885        // A job rewrites the binary in place and leaves the manifest alone.
10886        fs::write(seed.join("bin").join("Runner.Listener"), b"planted").unwrap();
10887        refresh_package_seed(&source, &seeds, &trusted).unwrap();
10888        assert_eq!(
10889            fs::read_to_string(seed.join("bin").join("Runner.Listener")).unwrap(),
10890            "2.336.0",
10891            "a seed whose print moved is rebuilt from the cache entry"
10892        );
10893
10894        // A restarted daemon has no print of a seed it did not build.
10895        // Keep the old inode allocated until the replacement is complete;
10896        // otherwise the filesystem may immediately reuse its number.
10897        let old_listener = fs::File::open(seed.join("bin").join("Runner.Listener")).unwrap();
10898        let built = listener_inode(&seed);
10899        refresh_package_seed(&source, &seeds, &TrustedTrees::default()).unwrap();
10900        assert_ne!(
10901            listener_inode(&seed),
10902            built,
10903            "an unrecorded seed is rebuilt"
10904        );
10905        drop(old_listener);
10906    }
10907
10908    #[cfg(unix)]
10909    #[test]
10910    fn a_package_seed_is_rebuilt_for_a_new_entry_and_drops_every_other() {
10911        let root = tempfile::tempdir().unwrap();
10912        let seeds = root.path().join("runners").join(PACKAGE_SEED_DIR);
10913        let trusted = TrustedTrees::default();
10914        let old = package_entry(root.path(), "2.335.0", "{\"digest\":\"old\"}");
10915        refresh_package_seed(&old, &seeds, &trusted).unwrap();
10916        fs::create_dir_all(seeds.join(".staging-interrupted")).unwrap();
10917        fs::write(seeds.join("stray"), b"").unwrap();
10918
10919        let new = package_entry(root.path(), "2.336.0", "{\"digest\":\"new\"}");
10920        let seed = refresh_package_seed(&new, &seeds, &trusted).unwrap();
10921
10922        let mut left: Vec<_> = fs::read_dir(&seeds)
10923            .unwrap()
10924            .map(|entry| entry.unwrap().file_name().into_string().unwrap())
10925            .collect();
10926        left.sort();
10927        assert_eq!(left, ["2.336.0"]);
10928
10929        // A reinstalled entry of the same version has a different manifest.
10930        fs::write(
10931            new.join(crate::package::MANIFEST_FILE),
10932            "{\"digest\":\"again\"}",
10933        )
10934        .unwrap();
10935        refresh_package_seed(&new, &seeds, &trusted).unwrap();
10936        assert_eq!(
10937            fs::read_to_string(seed.join(crate::package::MANIFEST_FILE)).unwrap(),
10938            "{\"digest\":\"again\"}",
10939            "a stale seed is rebuilt from the changed entry"
10940        );
10941    }
10942
10943    #[cfg(unix)]
10944    #[test]
10945    fn a_package_on_the_runtime_volume_is_its_own_seed() {
10946        let root = tempfile::tempdir().unwrap();
10947        let source = package_entry(root.path(), "2.336.0", "{}");
10948        let runtime = root.path().join("runners").join("attempt");
10949        fs::create_dir_all(&runtime).unwrap();
10950
10951        let trusted = TrustedTrees::default();
10952        assert_eq!(package_seed(&source, &runtime, &trusted).unwrap(), source);
10953        copy_package_tree(&source, &runtime, &trusted).unwrap();
10954        assert!(runtime.join("bin").join("Runner.Listener").exists());
10955        assert!(!root.path().join("runners").join(PACKAGE_SEED_DIR).exists());
10956    }
10957
10958    #[test]
10959    fn copy_package_tree_refuses_top_level_work_folder() {
10960        let root = tempfile::tempdir().unwrap();
10961        let source = root.path().join("source");
10962        let dest = root.path().join("dest");
10963
10964        fs::create_dir_all(&source).unwrap();
10965        fs::write(source.join("file1.txt"), b"hello").unwrap();
10966
10967        // Top-level `_work` should be refused
10968        let top_work = source.join(DEFAULT_WORK_FOLDER);
10969        fs::create_dir_all(&top_work).unwrap();
10970
10971        let err = copy_package_tree(&source, &dest, &TrustedTrees::default()).unwrap_err();
10972        assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
10973    }
10974}