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::oci::OciProcesses;
49use crate::package::{PackageCache, PackageError, RunnerVersion};
50use crate::reconcile::{
51    AllocationGuard, CleanBackoff, EventSink, LaunchFailure, LaunchRequest, LifecycleEvent,
52    OutcomeKind, ReplacementIntent, RunnerLauncher, failure_reason_kind,
53};
54
55mod windows_hyperv;
56pub use windows_hyperv::{WindowsHyperVContainers, WindowsHyperVHostState};
57
58const IDENTITY_FILE: &str = ".runner-process.json";
59const FALLBACK_IDENTITY_FILE: &str = ".runner-process.recovery.json";
60const UNRESOLVED_PROCESS_FILE: &str = ".runner-process.unresolved";
61const RUNNER_ID_FILE: &str = ".github-runner-id";
62const TERMINATE_INTENT_FILE: &str = ".terminate-registration-timeout";
63const MAX_POST_SPAWN_STOP_ATTEMPTS: usize = 3;
64
65/// Slot-root names a cleaned persistent attempt must not have left behind.
66///
67/// This is not the rule — the rule is that *nothing* but a real `_work`
68/// survives, and [`verify_slot_scrubbed`] enforces that by counting. This list
69/// is the second, independent question asked of the same directory: each name
70/// is stat-ed directly, so a scrub that skipped one is caught even if the
71/// enumeration that was supposed to find it under-reported. Every entry is one
72/// of the things `04-security-recovery.md` requires to be proven absent before a
73/// slot is released; the encoded JIT handoff is the one exception, matched by
74/// its published prefix in [`verify_slot_scrubbed`] because the rest of its name
75/// is a UUID. Being compile-time constants, these are also the only entry names
76/// a refusal message is allowed to print.
77const SENSITIVE_SLOT_ENTRIES: &[&str] = &[
78    // Runner binaries and the launchers beside them.
79    "bin",
80    "externals",
81    "run.sh",
82    "run.cmd",
83    "config.sh",
84    "config.cmd",
85    // The registration identity GitHub's runner writes for itself, and the
86    // per-run environment it reads back.
87    ".runner",
88    ".credentials",
89    ".credentials_rsaparams",
90    ".env",
91    ".path",
92    "_diag",
93    // This agent's own process-identity and lifecycle sidecars.
94    IDENTITY_FILE,
95    FALLBACK_IDENTITY_FILE,
96    UNRESOLVED_PROCESS_FILE,
97    RUNNER_ID_FILE,
98    TERMINATE_INTENT_FILE,
99];
100#[cfg(test)]
101const TEST_LISTENER_READY: &str = ".test-listener-ready";
102
103/// GitHub Runner v2.336.0 accepts JIT configuration for `run` through its
104/// secret `ACTIONS_RUNNER_INPUT_JITCONFIG` input. The platform spawn boundary
105/// supplies that input from the restrictive handoff; the listener command line
106/// must contain only the supported `run` command.
107///
108/// The environment is the daemon's plus [`runner_env::platform_defaults`] and
109/// then the host's `runner.env`, which wins over a default of the same name.
110/// The per-attempt temporary directory goes last and `runner.env` cannot name
111/// it. On macOS a runner that still starts throttled is reported
112/// ([`SpawnSpec::expect_normal_scheduling`]).
113fn runner_listener_spec(program: PathBuf, runtime: &Path, host_env: &RunnerEnv) -> SpawnSpec {
114    let tmp = runtime.join("tmp");
115    let _ = std::fs::create_dir_all(&tmp);
116    let defaults = runner_env::platform_defaults(
117        RunnerPlatform::current(),
118        runtime,
119        &runner_env::Inherited::current(),
120        Path::exists,
121    );
122    // The Windows profile the defaults point at. Created even when
123    // `runner.env` overrides part of it: it is inside the attempt, so it costs
124    // nothing and goes with the attempt.
125    for (name, dir) in &defaults {
126        if runner_env::DIRECTORY_VARIABLES.contains(name) {
127            let _ = std::fs::create_dir_all(dir);
128        }
129    }
130    let mut spec = SpawnSpec::new(program)
131        .arg("run")
132        .working_dir(runtime)
133        .expect_normal_scheduling();
134    for (name, value) in runner_env::runner_environment(defaults, host_env) {
135        spec = spec.env(name, value);
136    }
137    spec.env("TMPDIR", &tmp).env("TEMP", &tmp).env("TMP", &tmp)
138}
139
140/// Retry bounds for failures that can resolve without operator action.
141#[derive(Debug, Clone, Copy, PartialEq, Eq)]
142pub struct RetryPolicy {
143    pub max_attempts: u32,
144    pub initial: Duration,
145    pub maximum: Duration,
146}
147
148impl RetryPolicy {
149    #[must_use]
150    pub const fn bounded(max_attempts: u32, initial: Duration, maximum: Duration) -> Self {
151        Self {
152            max_attempts,
153            initial,
154            maximum,
155        }
156    }
157
158    pub(crate) fn delay(self, failure_index: u32) -> Duration {
159        let shift = failure_index.saturating_sub(1).min(31);
160        self.initial
161            .saturating_mul(1_u32 << shift)
162            .min(self.maximum)
163    }
164}
165
166/// Non-secret lifecycle evidence.  Payloads are identifiers and closed enums;
167/// neither the encoded configuration nor child output can enter this type.
168#[derive(Debug, Clone, PartialEq, Eq)]
169pub enum AttemptEvent {
170    State {
171        attempt: AttemptId,
172        state: AttemptState,
173    },
174    Retry {
175        attempt: AttemptId,
176        operation: &'static str,
177        delay: Duration,
178    },
179    /// How long each step of a launch took, whether or not it started a
180    /// runner, so a slow or failed start can be attributed to the wait for the
181    /// allocation lock, the package copy, the JIT request, or the process start.
182    Launched {
183        attempt: AttemptId,
184        started: bool,
185        timings: LaunchTimings,
186    },
187    Adopted {
188        attempt: AttemptId,
189    },
190    RemoteIdentityRecovered {
191        attempt: AttemptId,
192        runner_id: u64,
193    },
194    TerminateIntent {
195        attempt: AttemptId,
196    },
197    Terminated {
198        attempt: AttemptId,
199    },
200    /// The attempt's GitHub registration was removed by this agent. Carries the
201    /// runner id because that is the identifier an operator sees in the
202    /// target's runner settings, and the attempt id is not shown there.
203    Deregistered {
204        attempt: AttemptId,
205        runner_id: u64,
206    },
207    Concluded {
208        attempt: AttemptId,
209        outcome: OutcomeKind,
210    },
211    Cleaned {
212        attempt: AttemptId,
213        outcome: OutcomeKind,
214    },
215    QuarantinedOwnedResource {
216        attempt: AttemptId,
217    },
218}
219
220pub trait AttemptEventSink: fmt::Debug + Send + Sync {
221    fn emit(&self, event: AttemptEvent);
222}
223
224#[derive(Debug, Default)]
225pub struct AttemptEventLog(Mutex<Vec<AttemptEvent>>);
226
227impl AttemptEventLog {
228    #[must_use]
229    pub fn events(&self) -> Vec<AttemptEvent> {
230        self.0
231            .lock()
232            .map(|events| events.clone())
233            .unwrap_or_default()
234    }
235}
236
237impl AttemptEventSink for AttemptEventLog {
238    fn emit(&self, event: AttemptEvent) {
239        if let Ok(mut events) = self.0.lock() {
240            events.push(event);
241        }
242    }
243}
244
245#[derive(Debug, Clone, Copy, Default)]
246pub struct NoAttemptEvents;
247
248impl AttemptEventSink for NoAttemptEvents {
249    fn emit(&self, _event: AttemptEvent) {}
250}
251
252/// Where one launch spent its time. A step the launch never reached is zero.
253#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
254pub struct LaunchTimings {
255    /// Waiting for the host allocation lock, which another launch may hold.
256    pub lock_wait: Duration,
257    pub materialize: Duration,
258    pub register: Duration,
259    pub spawn: Duration,
260}
261
262impl LaunchTimings {
263    #[must_use]
264    pub fn total(&self) -> Duration {
265        self.lock_wait + self.materialize + self.register + self.spawn
266    }
267}
268
269#[derive(Debug, Clone, Copy)]
270enum LaunchStep {
271    Materialize,
272    Register,
273    Spawn,
274}
275
276/// Times a launch's steps; beginning one ends the one before, so an early
277/// return still leaves the step it failed in measured once [`Self::finish`]
278/// runs.
279#[derive(Debug)]
280struct LaunchStopwatch {
281    timings: LaunchTimings,
282    running: Option<(LaunchStep, std::time::Instant)>,
283}
284
285impl LaunchStopwatch {
286    fn new(lock_wait: Duration) -> Self {
287        Self {
288            timings: LaunchTimings {
289                lock_wait,
290                ..LaunchTimings::default()
291            },
292            running: None,
293        }
294    }
295
296    fn begin(&mut self, step: LaunchStep) {
297        self.finish();
298        self.running = Some((step, std::time::Instant::now()));
299    }
300
301    fn finish(&mut self) {
302        if let Some((step, since)) = self.running.take() {
303            let slot = match step {
304                LaunchStep::Materialize => &mut self.timings.materialize,
305                LaunchStep::Register => &mut self.timings.register,
306                LaunchStep::Spawn => &mut self.timings.spawn,
307            };
308            *slot = since.elapsed();
309        }
310    }
311}
312
313/// A launch slower than this is logged as a warning, with its steps.
314///
315/// A healthy launch is a few seconds; a queued job waits for all of it, and
316/// the daemon's default filter drops the `info` line that times every launch.
317const SLOW_LAUNCH: Duration = Duration::from_secs(60);
318
319/// The daemon's sink for retries and launch timings; the other attempt events
320/// already reach the log through the reconcile sink.
321///
322/// Every field name is on `d1`'s allow-list, or the redacting layer would log
323/// `[redacted]` in its place; `tests::every_field_name_the_attempt_sink_emits_is_allowed`
324/// keeps that true.
325#[derive(Debug, Clone, Copy, Default)]
326pub struct TracingAttemptEvents;
327
328impl AttemptEventSink for TracingAttemptEvents {
329    fn emit(&self, event: AttemptEvent) {
330        match event {
331            AttemptEvent::Retry {
332                attempt,
333                operation,
334                delay,
335            } => tracing::warn!(
336                event = "attempt_retry",
337                attempt_id = %attempt,
338                reason = operation,
339                retry_in_ms = millis(delay),
340            ),
341            AttemptEvent::Launched {
342                attempt,
343                started,
344                timings,
345            } => {
346                macro_rules! launched {
347                    ($level:ident, $name:literal) => {
348                        tracing::$level!(
349                            event = $name,
350                            attempt_id = %attempt,
351                            duration_ms = millis(timings.total()),
352                            lock_wait_ms = millis(timings.lock_wait),
353                            materialize_ms = millis(timings.materialize),
354                            register_ms = millis(timings.register),
355                            spawn_ms = millis(timings.spawn),
356                        )
357                    };
358                }
359                if !started {
360                    launched!(warn, "attempt_launch_failed");
361                } else if timings.total() >= SLOW_LAUNCH {
362                    launched!(warn, "attempt_launch_slow");
363                } else {
364                    launched!(info, "attempt_launched");
365                }
366            }
367            _ => {}
368        }
369    }
370}
371
372fn millis(duration: Duration) -> u64 {
373    u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
374}
375
376/// Whether the demand that justified a retry still exists.
377#[async_trait]
378pub trait DemandPersistence: fmt::Debug + Send + Sync {
379    async fn persists(&self, policy: PolicyId) -> bool;
380}
381
382#[derive(Debug, Clone, Copy, Default)]
383pub struct PersistentDemand;
384
385#[async_trait]
386impl DemandPersistence for PersistentDemand {
387    async fn persists(&self, _policy: PolicyId) -> bool {
388        true
389    }
390}
391
392#[async_trait]
393pub trait RetryDelay: fmt::Debug + Send + Sync {
394    async fn wait(&self, duration: Duration);
395}
396
397#[derive(Debug, Clone, Copy, Default)]
398pub struct TokioRetryDelay;
399
400#[async_trait]
401impl RetryDelay for TokioRetryDelay {
402    async fn wait(&self, duration: Duration) {
403        tokio::time::sleep(duration).await;
404    }
405}
406
407#[derive(Debug, Clone, PartialEq, Eq)]
408pub struct JitRequestFailure {
409    pub terminal: bool,
410    pub reason: FailureReason,
411    pub retry_after: Option<Duration>,
412}
413
414/// GitHub's authoritative runner state plus the identity returned by inventory.
415/// The id is carried independently of the local sidecar so recovery can close
416/// the crash boundary immediately after a successful remote registration.
417#[derive(Debug, Clone, Copy, PartialEq, Eq)]
418pub struct LifecycleGithubObservation {
419    pub status: GithubRunnerObservation,
420    pub runner_id: Option<u64>,
421}
422
423impl LifecycleGithubObservation {
424    #[must_use]
425    pub const fn unreachable() -> Self {
426        Self {
427            status: GithubRunnerObservation::Unreachable,
428            runner_id: None,
429        }
430    }
431
432    #[must_use]
433    pub const fn not_registered() -> Self {
434        Self {
435            status: GithubRunnerObservation::NotRegistered,
436            runner_id: None,
437        }
438    }
439
440    #[must_use]
441    pub const fn registered(runner_id: u64, busy: bool) -> Self {
442        Self {
443            status: GithubRunnerObservation::Registered { busy },
444            runner_id: Some(runner_id),
445        }
446    }
447}
448
449/// The two GitHub views the lifecycle needs, combined so one fake can drive
450/// registration and authoritative runner telemetry.
451#[async_trait]
452pub trait LifecycleGithub: fmt::Debug + Send + Sync {
453    async fn register(
454        &self,
455        target: &ScaleTarget,
456        request: &JitRunnerRequest,
457        cancel: &CancelToken,
458    ) -> Result<JitRegistration, JitRequestFailure>;
459
460    async fn observe(
461        &self,
462        target: &ScaleTarget,
463        attempt: AttemptId,
464        cancel: &CancelToken,
465    ) -> LifecycleGithubObservation;
466
467    /// Remove one runner registration this agent created.
468    ///
469    /// Answers whether the registration is gone, and is deliberately not
470    /// fallible in the `Result` sense: no caller may abandon a conclusion
471    /// because GitHub was unreachable. See
472    /// [`LifecycleLauncher::deregister_runner`].
473    async fn deregister(&self, target: &ScaleTarget, runner_id: u64, cancel: &CancelToken) -> bool;
474}
475
476#[async_trait]
477impl<T> LifecycleGithub for T
478where
479    T: JitGateway + InventoryGateway + fmt::Debug + Send + Sync,
480{
481    async fn register(
482        &self,
483        target: &ScaleTarget,
484        request: &JitRunnerRequest,
485        cancel: &CancelToken,
486    ) -> Result<JitRegistration, JitRequestFailure> {
487        self.generate_jit_config(target, request, cancel)
488            .await
489            .map_err(|error| {
490                let reason = if matches!(&error, JitError::Forbidden { .. }) {
491                    FailureReason::Other(
492                        "GitHub refused JIT registration with 403; check the App's runner permission and runner-group access"
493                            .into(),
494                    )
495                } else {
496                    FailureReason::JitRequestFailed
497                };
498                JitRequestFailure {
499                    terminal: error.is_terminal(),
500                    reason,
501                    retry_after: error
502                        .rate_limited()
503                        .map(|limit| limit.delay_from(self.now())),
504                }
505            })
506    }
507
508    async fn observe(
509        &self,
510        target: &ScaleTarget,
511        attempt: AttemptId,
512        cancel: &CancelToken,
513    ) -> LifecycleGithubObservation {
514        let expected_name = runner_name(attempt);
515        match self.list_runners(target, cancel).await {
516            Ok(inventory) => inventory
517                .runners()
518                .iter()
519                .find(|runner| runner.name == expected_name)
520                .map_or(LifecycleGithubObservation::not_registered(), |runner| {
521                    LifecycleGithubObservation::registered(runner.id, runner.busy)
522                }),
523            Err(_) => LifecycleGithubObservation::unreachable(),
524        }
525    }
526
527    async fn deregister(&self, target: &ScaleTarget, runner_id: u64, cancel: &CancelToken) -> bool {
528        self.remove_runner(target, runner_id, cancel).await.is_ok()
529    }
530}
531
532/// Package/cache operations used by one attempt.
533#[async_trait]
534pub trait RuntimePackages: fmt::Debug + Send + Sync {
535    async fn materialize(&self, attempt: &RunnerAttempt) -> Result<RunnerVersion, FailureReason>;
536    fn release(&self, attempt: AttemptId) -> Result<(), FailureReason>;
537    fn prune_obsolete_guarded(
538        &self,
539        authority: PruneAuthority<'_>,
540        current: &RunnerVersion,
541        attempts: &[RunnerAttempt],
542    ) -> Result<(), FailureReason>;
543}
544
545/// Unforgeable evidence that pruning was reached through e1's launch request.
546/// The type is public only because it appears in the public adapter trait; its
547/// private field and constructor prevent callers from substituting a guard
548/// acquired from an unrelated lock.
549pub struct PruneAuthority<'a> {
550    _guard: &'a AllocationGuard,
551}
552
553impl fmt::Debug for PruneAuthority<'_> {
554    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
555        f.write_str("PruneAuthority")
556    }
557}
558
559impl<'a> PruneAuthority<'a> {
560    fn from_launch_request(guard: &'a AllocationGuard) -> Self {
561        Self { _guard: guard }
562    }
563}
564
565/// Production package adapter.  It takes an e2 lease before returning, so a
566/// cache entry can never look unused while its runtime is starting.
567#[derive(Debug)]
568pub struct CachedRuntimePackages {
569    cache: Arc<PackageCache>,
570    trusted: Arc<TrustedTrees>,
571}
572
573impl CachedRuntimePackages {
574    /// Run `work` against a package tree on the blocking pool: a fingerprint
575    /// walks ~9,000 entries and a copy can take minutes.
576    async fn blocking<T: Send + 'static>(
577        &self,
578        root: &Path,
579        work: impl FnOnce(&TrustedTrees, &Path) -> T + Send + 'static,
580    ) -> Result<T, FailureReason> {
581        let trusted = Arc::clone(&self.trusted);
582        let root = root.to_path_buf();
583        tokio::task::spawn_blocking(move || work(&trusted, &root))
584            .await
585            .map_err(|_| FailureReason::ProcessStartFailed)
586    }
587
588    #[must_use]
589    pub fn new(cache: Arc<PackageCache>) -> Self {
590        Self {
591            cache,
592            trusted: Arc::default(),
593        }
594    }
595}
596
597#[async_trait]
598impl RuntimePackages for CachedRuntimePackages {
599    async fn materialize(&self, attempt: &RunnerAttempt) -> Result<RunnerVersion, FailureReason> {
600        let (mut installed, downloaded) = self
601            .cache
602            .ensure_installed_reporting()
603            .await
604            .map_err(package_failure)?;
605        // A cache entry this process did not install, or one that has changed
606        // since, may hold a job's edits; replace it with one downloaded and
607        // verified now. One this call just downloaded is trusted as it lands.
608        // See `TrustedTrees`.
609        let fresh = if downloaded {
610            true
611        } else if self.blocking(installed.root(), TrustedTrees::holds).await? {
612            false
613        } else {
614            installed = self.cache.reinstall().await.map_err(package_failure)?;
615            true
616        };
617        if fresh {
618            self.blocking(installed.root(), TrustedTrees::record)
619                .await?
620                .map_err(|_| FailureReason::ProcessStartFailed)?;
621        }
622        // Off the async thread: the daemon runs a current-thread runtime, and a
623        // copy (or a seed rebuild) can take minutes on a busy disk.
624        let destination = attempt.runtime_path().to_path_buf();
625        let copied = self
626            .blocking(installed.root(), move |trusted, source| {
627                copy_package_tree(source, &destination, trusted)?;
628                // Checked again after the copy: an edit a job made while it ran
629                // moved the entry's print, and the runtime may hold it.
630                if trusted.holds(source) {
631                    Ok(())
632                } else {
633                    Err(std::io::Error::other(
634                        "the runner package changed while copied",
635                    ))
636                }
637            })
638            .await?;
639        if copied.is_err() {
640            // Nothing a failed or tainted copy left may reach a retry's copy,
641            // which lays out over the same runtime.
642            let _ = remove_materialized_package(attempt);
643            return Err(FailureReason::ProcessStartFailed);
644        }
645        if let Err(error) = self.cache.lease(attempt, installed.version()) {
646            // Undoing the copy must not undo the *job* workspace: a persistent
647            // slot's `_work` is retained across attempts, and this rollback
648            // runs before the attempt that would have owned it ever started.
649            let _ = remove_materialized_package(attempt);
650            return Err(package_failure(error));
651        }
652        Ok(installed.version().clone())
653    }
654
655    fn release(&self, attempt: AttemptId) -> Result<(), FailureReason> {
656        self.cache.release(attempt).map_err(package_failure)
657    }
658
659    fn prune_obsolete_guarded(
660        &self,
661        _authority: PruneAuthority<'_>,
662        current: &RunnerVersion,
663        attempts: &[RunnerAttempt],
664    ) -> Result<(), FailureReason> {
665        for installed in self.cache.installed().map_err(package_failure)? {
666            if installed.version() != current {
667                match self.cache.prune(installed.version(), attempts) {
668                    Ok(()) | Err(PackageError::VersionInUse { .. }) => {}
669                    Err(error) => return Err(package_failure(error)),
670                }
671            }
672        }
673        Ok(())
674    }
675}
676
677fn package_failure(error: PackageError) -> FailureReason {
678    error.failure_reason().unwrap_or(FailureReason::Other(
679        "runner package cache operation failed".into(),
680    ))
681}
682
683fn package_failure_is_terminal(reason: &FailureReason) -> bool {
684    matches!(
685        reason,
686        FailureReason::RunnerPackageUnverified | FailureReason::RunnerVersionRejected
687    )
688}
689
690/// How many hex characters of the attempt id name its workspace.
691///
692/// # Why this is not the whole identifier, and why the policy is not in the path
693///
694/// Windows refuses a path over `MAX_PATH`, and the runner writes deep inside
695/// this directory: `_work/<repo>/<repo>/.git/objects/pack/pack-<40 hex>.keep`
696/// is 100 characters on its own before the repository is named twice. The
697/// layout used to add two full identifiers -- the policy's and the attempt's,
698/// 74 characters between them -- and that was enough to put a real checkout
699/// over the line. Measured, not guessed: this repository's own CI failed here
700/// three times in a row at 264 characters against a limit of 260, with
701/// `fatal: cannot write keep file ...: Filename too long`. A repository whose
702/// name is ten characters longer would have missed by fourteen.
703///
704/// The policy identifier is simply redundant -- an attempt identifier is
705/// unique on its own, and nothing reads the directory tree to find a policy's
706/// attempts, because [`crate::lifecycle::LifecycleLauncher`] asks the journal.
707/// Twelve hex characters of the attempt is 48 bits, which for the handful of
708/// directories one host holds at once is not a collision anybody will see, and
709/// the journal keeps the full identifier either way.
710///
711/// Together that is 61 characters returned to the repository name.
712const WORKSPACE_NAME_LEN: usize = 12;
713
714/// The directory name for one attempt's workspace.
715fn workspace_name(id: AttemptId) -> String {
716    let full = id.to_string();
717    full.chars()
718        .filter(|c| *c != '-')
719        .take(WORKSPACE_NAME_LEN)
720        .collect()
721}
722
723/// Where one attempt's files go, and which cleanup algorithm they are owed.
724///
725/// The pair travels together because journalling them apart is exactly the bug
726/// `AttemptWorkspace` exists to prevent: a `runtime_path` under a persistent
727/// root recorded as ephemeral would be removed whole, taking the retained job
728/// workspace with it.
729#[derive(Debug, Clone)]
730struct Placement {
731    runtime: PathBuf,
732    workspace: AttemptWorkspace,
733}
734
735/// A runner-root refusal, rendered for the operator who has to fix it.
736///
737/// `RunnerRootError`'s `Display` already names the path, the relation and the
738/// remediation command, and none of its variants can carry a credential — they
739/// are paths, and `03-migration-rollout.md` requires the remediation command to
740/// reach the operator verbatim.
741fn root_failure(error: RunnerRootError) -> LifecycleError {
742    // Logged here because here is the last place this is known at all. A launch
743    // refused by the runner root fails *before* `record_allocation`, so no
744    // attempt row is ever written: `b2` has nothing to carry the failure on and
745    // `g2` has nothing to show it from. The lifecycle event keeps only
746    // `reason=other`, by `failure_reason_kind`'s rule that no free text may
747    // reach an event -- which left the whole refusal reading
748    // `runner_start_failed reason=other`, once per poll, naming nothing.
749    //
750    // What travels is the *kind* and not the sentence, and that is forced rather
751    // than chosen: `crate::logging` redacts every field it does not allow-list
752    // and then scrubs anything path-shaped out of the ones it does, so a
753    // rendered `RunnerRootError` -- which is mostly paths -- reaches the log as
754    // `[redacted]`. `error_kind` is allow-listed and `RunnerRootError::kind` is
755    // a closed vocabulary that survives the scrub, so this names which of a
756    // dozen causes the operator has. The paths and the remediation reach them
757    // through the command line, which is not redacted.
758    tracing::warn!(
759        error_kind = error.kind(),
760        "the runner root refused this launch, so no attempt was created; the host will \
761         retry every poll until the cause is resolved. Re-running `host set-runtime-root` \
762         with the same path re-runs this check and prints the directory and the \
763         remediation in full"
764    );
765    LifecycleError::Failed(FailureReason::Other(error.to_string()))
766}
767
768/// The lowest positive slot inside `ceiling` that no uncleaned attempt holds.
769///
770/// `leases` is the journal's answer to "which slots are leased"
771/// (`Store::slot_leases_for_policy`), which deliberately includes a terminal
772/// attempt whose cleanup has not finished: that attempt still owns its
773/// directory, so its slot is not free even though it no longer counts against
774/// host capacity. `None` means the ceiling is reached, which is a refusal and
775/// not a reason to allocate `s(ceiling + 1)`.
776fn lowest_free_slot(leases: &[RunnerAttempt], ceiling: NonZeroU16) -> Option<NonZeroU16> {
777    let held: BTreeSet<u16> = leases
778        .iter()
779        .filter_map(|attempt| attempt.workspace().slot_number())
780        .collect();
781    (1..=ceiling.get())
782        .find(|slot| !held.contains(slot))
783        .and_then(NonZeroU16::new)
784}
785
786/// Create `<root>/sN`, or prove that what is already there is a real directory.
787///
788/// A symlink, junction or reparse point standing where the slot should be is
789/// refused rather than followed: it is the one thing that could put an attempt's
790/// files outside the root the operator configured, and
791/// `04-security-recovery.md` requires that case to fail closed rather than to
792/// be repaired here.
793fn create_or_validate_slot(slot: &Path) -> Result<(), LifecycleError> {
794    match fs::symlink_metadata(slot) {
795        // [`is_link_like`] and not `is_symlink`, so that this is the same
796        // question cleanup asks in [`slot_is_present`]: a reparse tag the
797        // standard library has no name for is refused here rather than
798        // allocated into and then quarantined forever by a cleanup that will
799        // not scrub it.
800        Ok(metadata) if is_link_like(&metadata) => Err(slot_refusal(
801            slot,
802            "is a symbolic link, junction or other reparse point, which could place runner \
803             files outside the configured root",
804        )),
805        Ok(metadata) if !metadata.is_dir() => Err(slot_refusal(slot, "is not a directory")),
806        Ok(_) => Ok(()),
807        Err(error) if error.kind() == std::io::ErrorKind::NotFound => fs::create_dir(slot)
808            .map_err(|source| slot_refusal(slot, format!("could not be created: {source}"))),
809        Err(source) => Err(slot_refusal(
810            slot,
811            format!("could not be inspected: {source}"),
812        )),
813    }
814}
815
816/// Accept a slot for reuse only when it is empty or holds one real `_work`.
817///
818/// `02-target-architecture.md`: "Before materialization, a reusable slot must
819/// contain only a valid real `_work` directory or be empty." Everything else —
820/// a leftover `bin/`, a link-shaped `_work`, a stray file — is refused here
821/// rather than cleaned, because deciding whether those bytes are safe is
822/// cleanup's and recovery's job (`c3`), and quietly reusing them would hand one
823/// repository's retained state to the next attempt without anybody choosing to.
824///
825/// The inspection is one level deep and uses `symlink_metadata`, so nothing is
826/// followed while it is being judged.
827fn accept_reusable_slot(slot: &Path) -> Result<(), LifecycleError> {
828    let unreadable =
829        |source: std::io::Error| slot_refusal(slot, format!("could not be read: {source}"));
830    let entries = fs::read_dir(slot).map_err(unreadable)?;
831    let mut refused: Vec<String> = Vec::new();
832    for entry in entries {
833        let entry = entry.map_err(unreadable)?;
834        let name = entry.file_name();
835        let metadata = fs::symlink_metadata(entry.path()).map_err(|source| {
836            slot_refusal(
837                slot,
838                format!("entry {name:?} could not be inspected: {source}"),
839            )
840        })?;
841        // The same predicate cleanup retains by, so a `_work` this accepts is
842        // one [`scrub_slot_entries`] will keep rather than refuse: a link, a
843        // junction or any other reparse point is not a job workspace to either
844        // of them.
845        if is_retainable_work_folder(&name, &metadata) {
846            continue;
847        }
848        refused.push(name.to_string_lossy().into_owned());
849    }
850    if refused.is_empty() {
851        return Ok(());
852    }
853    refused.sort();
854    Err(slot_refusal(
855        slot,
856        format!(
857            "holds {} that this attempt may not reuse: [{}]. A reusable slot is empty or holds \
858             one real `{DEFAULT_WORK_FOLDER}` directory and nothing else; remove or move the \
859             entries listed, or let cleanup and recovery resolve them",
860            if refused.len() == 1 {
861                "an entry"
862            } else {
863                "entries"
864            },
865            refused.join(", ")
866        ),
867    ))
868}
869
870fn slot_refusal(slot: &Path, detail: impl fmt::Display) -> LifecycleError {
871    LifecycleError::Failed(FailureReason::Other(format!(
872        "the persistent slot {} {detail}",
873        slot.display()
874    )))
875}
876
877/// Whether a directory entry names the retained job workspace.
878///
879/// The comparison folds case on Windows because the filesystem does: there
880/// `_Work` and `_work` are one directory, so a case-sensitive test would let
881/// [`scrub_slot_entries`] delete the very directory it exists to keep, let
882/// [`accept_reusable_slot`] refuse a slot that holds nothing but a valid job
883/// workspace, and let a package's top-level `_Work` merge itself into the
884/// previous attempt's `_work`. Elsewhere the two names really are two
885/// directories and only the exact one is the job workspace.
886fn is_work_folder(name: &OsStr) -> bool {
887    if cfg!(windows) {
888        name.eq_ignore_ascii_case(DEFAULT_WORK_FOLDER)
889    } else {
890        name == OsStr::new(DEFAULT_WORK_FOLDER)
891    }
892}
893
894/// Whether the operating system would follow this entry somewhere else.
895///
896/// `FileType::is_symlink` is the whole answer on Unix. On Windows it is not:
897/// the standard library reports only the symlink and mount-point reparse tags,
898/// and the substitution this has to refuse is *any* reparse point standing
899/// where a real directory should be. So the attribute bit is the test there,
900/// and a tag the standard library has no name for fails closed with the two it
901/// does.
902fn is_link_like(metadata: &fs::Metadata) -> bool {
903    if metadata.file_type().is_symlink() {
904        return true;
905    }
906    #[cfg(windows)]
907    {
908        use std::os::windows::fs::MetadataExt;
909
910        const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
911        metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
912    }
913    #[cfg(not(windows))]
914    false
915}
916
917/// Whether an entry is the retained job workspace and is safe to retain.
918///
919/// The two halves are one question. A `_work` that is a file, a symlink, a
920/// junction or any other reparse point is not a job workspace, and it is also
921/// the exact substitution a hostile workflow makes to send cleanup somewhere
922/// else (`04-security-recovery.md`, "A workflow replaces `_work` with a
923/// junction or symlink to escape cleanup").
924fn is_retainable_work_folder(name: &OsStr, metadata: &fs::Metadata) -> bool {
925    is_work_folder(name) && metadata.is_dir() && !is_link_like(metadata)
926}
927
928/// Undo one package materialization, dispatching on the journalled workspace.
929///
930/// The ephemeral half is what this always did: the directory is the attempt's
931/// alone, so it goes whole. The persistent half removes the copy and nothing
932/// else, because the slot's `_work` predates this attempt and outlives it
933/// (`02-target-architecture.md`, "Persistent repository").
934fn remove_materialized_package(attempt: &RunnerAttempt) -> std::io::Result<()> {
935    match attempt.workspace() {
936        AttemptWorkspace::Ephemeral => remove_runtime_tree(attempt.runtime_path()),
937        AttemptWorkspace::PersistentSlot { .. } => scrub_slot_entries(attempt.runtime_path())
938            .map_err(|quarantine| std::io::Error::other(quarantine.to_string())),
939    }
940}
941
942/// Remove a disposable runner tree without opening workflow-created special files.
943///
944/// On Unix, `remove_dir_all` 1.0 can open a FIFO while walking a directory and
945/// wait forever for its peer. The .NET runner routinely leaves diagnostic FIFOs
946/// in its private `tmp`, so use the standard library's fd-relative Unix remover,
947/// which unlinks non-directories without opening them. Keep the external remover
948/// on Windows for its existing read-only and long-path handling; when it is
949/// refused, [`unlink_reparse_points`] takes every link out of its way and it
950/// runs once more.
951fn remove_runtime_tree(path: &Path) -> std::io::Result<()> {
952    #[cfg(windows)]
953    {
954        match remove_dir_all::remove_dir_all(path) {
955            Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
956                unlink_reparse_points(path);
957                remove_dir_all::remove_dir_all(path)
958            }
959            other => other,
960        }
961    }
962    #[cfg(not(windows))]
963    {
964        fs::remove_dir_all(path)
965    }
966}
967
968/// Remove every symlink, junction and other reparse point under `dir` as a
969/// link, without ever descending into one.
970///
971/// WinINet creates `…\INetCache\Content.IE5` as a junction whose own DACL denies
972/// `Everyone` the right to list it, and a job that points `USERPROFILE` or
973/// `LOCALAPPDATA` into its workspace gets one there. Both `remove_dir_all` 1.0
974/// and `std::fs::remove_dir_all` then fail on the whole tree with
975/// `ERROR_ACCESS_DENIED`, so the attempt could never be cleaned. Removing the
976/// link itself is a different operation (`RemoveDirectoryW` on the reparse
977/// point) that the deny entry does not cover, and once the links are gone the
978/// tree is ordinary.
979///
980/// Only walked after a refusal, so an ordinary tree is traversed once. The
981/// metadata comes from the directory listing itself, which on Windows
982/// describes a reparse point without opening it.
983///
984/// Best effort: a failure here is not reported, because
985/// [`remove_runtime_tree`]'s second `remove_dir_all` runs next and its result
986/// is the authority on whether the tree went.
987#[cfg(windows)]
988fn unlink_reparse_points(dir: &Path) {
989    let Ok(entries) = fs::read_dir(dir) else {
990        return;
991    };
992    for entry in entries.flatten() {
993        let path = entry.path();
994        let Ok(metadata) = entry.metadata() else {
995            continue;
996        };
997        if is_link_like(&metadata) {
998            let _ = fs::remove_dir(&path).or_else(|_| fs::remove_file(&path));
999        } else if metadata.is_dir() {
1000            unlink_reparse_points(&path);
1001        }
1002    }
1003}
1004
1005// ---------------------------------------------------------------------------
1006// Persistent cleanup (`04-security-recovery.md`, "Safe path handling")
1007// ---------------------------------------------------------------------------
1008
1009/// Why a persistent slot could not be proven safe to scrub.
1010///
1011/// A closed set rather than a formatted string, for two reasons that point the
1012/// same way. [`LifecycleEvent::AttemptCleanFailed`] takes a `&'static str` for
1013/// exactly the reason [`crate::reconcile::failure_reason_kind`] documents —
1014/// free text is the one shape that can carry a credential past a field
1015/// allow-list. And the entries under a slot root are *workflow-controlled*: a
1016/// job that writes a file named after a secret would publish it through any
1017/// message that echoed a directory listing, which is why nothing here ever
1018/// renders an entry name that did not come from this module's own constants.
1019#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1020enum SlotRefusal {
1021    /// The journalled runtime path is not `<root>/sN` for the journalled slot.
1022    NotTheJournalledSlot,
1023    /// A policy that still exists names a different root than the journal does.
1024    PolicyRootDisagrees,
1025    /// The slot is not strictly inside its root once components resolve.
1026    Containment,
1027    /// The slot itself is a file, a link, or could not be inspected.
1028    SlotNotADirectory,
1029    /// The slot's direct entries could not be listed.
1030    Enumeration,
1031    /// `_work` is a file, a symlink, a junction or another reparse point.
1032    WorkNotADirectory,
1033    /// An entry that had to go could not be removed.
1034    Deletion,
1035    /// Something other than the job workspace survived removal.
1036    Residue,
1037}
1038
1039impl SlotRefusal {
1040    /// The event field: a fixed vocabulary, never operator or workflow text.
1041    const fn class(self) -> &'static str {
1042        match self {
1043            Self::NotTheJournalledSlot => "slot_path_is_not_the_journalled_slot",
1044            Self::PolicyRootDisagrees => "slot_root_disagrees_with_policy",
1045            Self::Containment => "slot_escapes_its_root",
1046            Self::SlotNotADirectory => "slot_is_not_a_directory",
1047            Self::Enumeration => "slot_could_not_be_enumerated",
1048            Self::WorkNotADirectory => "retained_work_is_not_a_directory",
1049            Self::Deletion => "slot_entry_could_not_be_removed",
1050            Self::Residue => "slot_still_holds_runner_state",
1051        }
1052    }
1053
1054    /// What the operator has to do, in one sentence and with no path in it.
1055    const fn remediation(self) -> &'static str {
1056        match self {
1057            Self::NotTheJournalledSlot | Self::PolicyRootDisagrees | Self::Containment => {
1058                "the attempt keeps its slot lease and nothing was removed; correct the \
1059                 repository's persistent workspace path, or remove the slot directory by hand \
1060                 once you have confirmed what is in it"
1061            }
1062            Self::SlotNotADirectory | Self::WorkNotADirectory => {
1063                "the attempt keeps its slot lease and nothing was removed; a job replaced the \
1064                 slot or its `_work` with a link, so inspect it before deleting anything and \
1065                 treat the retained workspace as untrusted"
1066            }
1067            Self::Enumeration | Self::Deletion | Self::Residue => {
1068                "the attempt keeps its slot lease and will be cleaned again on the next pass; \
1069                 release whatever is holding the files open, or remove the slot's contents by \
1070                 hand leaving only `_work`"
1071            }
1072        }
1073    }
1074}
1075
1076/// A refusal that leaves one persistent slot quarantined.
1077///
1078/// `detail` is redacted by construction: it may hold paths this product
1079/// configured, `std::io::ErrorKind` values, counts, and names drawn from
1080/// [`SENSITIVE_SLOT_ENTRIES`] — and nothing that came out of a directory
1081/// listing.
1082#[derive(Debug, Clone, PartialEq, Eq)]
1083struct SlotQuarantine {
1084    refusal: SlotRefusal,
1085    detail: String,
1086}
1087
1088impl SlotQuarantine {
1089    fn new(refusal: SlotRefusal, detail: impl Into<String>) -> Self {
1090        Self {
1091            refusal,
1092            detail: detail.into(),
1093        }
1094    }
1095}
1096
1097impl fmt::Display for SlotQuarantine {
1098    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1099        write!(f, "{}; {}", self.detail, self.refusal.remediation())
1100    }
1101}
1102
1103/// Steps 1 to 3: the journalled root, the journalled slot, and containment.
1104///
1105/// Everything comes from the two immutable allocation facts — the exact runtime
1106/// path and the slot number. Not from the filesystem: scanning a root to decide
1107/// which directories are "mine" is what invariant 6 forbids, and it is also
1108/// impossible for an attempt whose policy has since been deleted. `configured`
1109/// is therefore a *cross-check* and not a source. A policy that survives has to
1110/// agree; a policy that does not survive removes a check rather than the
1111/// ability to clean the directory the journal already names.
1112///
1113/// The name test comes before the parent is used, so `<root>/s1` for a journal
1114/// row that says `s2` — and anything at all that is not one `sN` component —
1115/// is refused before a root is derived from it.
1116fn verify_journalled_slot(
1117    runtime: &Path,
1118    slot: NonZeroU16,
1119    configured: Option<&LocalAbsolutePath>,
1120) -> Result<(), SlotQuarantine> {
1121    let mislaid = || {
1122        SlotQuarantine::new(
1123            SlotRefusal::NotTheJournalledSlot,
1124            format!(
1125                "the journalled runtime {} is not the slot s{slot} this attempt was allocated as",
1126                runtime.display()
1127            ),
1128        )
1129    };
1130    let local = |path: &Path| {
1131        path.to_str()
1132            .and_then(|raw| LocalAbsolutePath::new(raw).ok())
1133            .ok_or_else(mislaid)
1134    };
1135
1136    let runtime_path = local(runtime)?;
1137    let root = local(runtime.parent().ok_or_else(mislaid)?)?;
1138    // Containment's lexical half, by construction: `derive_child` accepts one
1139    // component, so the equality below can only hold when the journalled path
1140    // really is this root's `sN` and nothing else.
1141    // The directory name comes from the domain that allocation named it with,
1142    // never from a second `s{n}` spelled out here: a convention with two
1143    // spellings would let cleanup refuse every slot the allocator created.
1144    let name = AttemptWorkspace::persistent_slot(slot)
1145        .slot_directory_name()
1146        .expect("a persistent workspace names its slot directory");
1147    let derived = runner_root::derive_child(&root, &name).map_err(|_| mislaid())?;
1148    if derived != runtime_path {
1149        return Err(mislaid());
1150    }
1151    if let Some(configured) = configured
1152        && configured != &root
1153    {
1154        return Err(SlotQuarantine::new(
1155            SlotRefusal::PolicyRootDisagrees,
1156            format!(
1157                "the journalled slot {} is not under the repository's configured persistent root \
1158                 {}",
1159                runtime.display(),
1160                configured.as_str()
1161            ),
1162        ));
1163    }
1164    // And containment's canonical half, which is what a junction planted inside
1165    // the root between allocation and cleanup has to get past.
1166    runner_root::verify_containment(&root, &derived).map_err(|source| {
1167        SlotQuarantine::new(
1168            SlotRefusal::Containment,
1169            format!("the journalled slot is not inside the root it was allocated from: {source}"),
1170        )
1171    })
1172}
1173
1174/// Whether the slot is there to be scrubbed at all, before its entries are.
1175///
1176/// `Ok(false)` — the directory is gone — is not a refusal. There is nothing to
1177/// remove and nothing to prove absent, which is the same tolerance the
1178/// disposable arm has always had for a runtime that vanished under it.
1179///
1180/// A slot that is a file, a link, a junction or any other reparse point *is* a
1181/// refusal, and it is checked here rather than inside the enumeration so that
1182/// the reason an operator reads names the shape rather than reporting that a
1183/// directory could not be listed.
1184fn slot_is_present(slot: &Path) -> Result<bool, SlotQuarantine> {
1185    match fs::symlink_metadata(slot) {
1186        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
1187        Err(source) => Err(SlotQuarantine::new(
1188            SlotRefusal::SlotNotADirectory,
1189            format!(
1190                "the slot {} could not be inspected: {:?}",
1191                slot.display(),
1192                source.kind()
1193            ),
1194        )),
1195        Ok(metadata) if !metadata.is_dir() || is_link_like(&metadata) => Err(SlotQuarantine::new(
1196            SlotRefusal::SlotNotADirectory,
1197            format!(
1198                "the slot {} is a link or a file rather than a real directory",
1199                slot.display()
1200            ),
1201        )),
1202        Ok(_) => Ok(true),
1203    }
1204}
1205
1206/// Steps 4 to 6: list the slot's direct entries, keep one real `_work`, remove
1207/// every other one.
1208///
1209/// Every call is a literal filesystem API against a path built from the slot
1210/// root and one entry name — no glob, no shell string, no repository-controlled
1211/// fragment. Nothing is followed: `symlink_metadata` says what each entry *is*,
1212/// and a link-shaped entry is unlinked rather than descended into, so a junction
1213/// planted where `bin` used to be cannot take the deletion outside the slot.
1214///
1215/// A `_work` that is not a real directory is the one entry this refuses to act
1216/// on at all. Removing it could destroy an operator's data if the link were
1217/// theirs; keeping it would hand the next attempt a workspace pointing
1218/// anywhere. `04-security-recovery.md` requires that case to quarantine the
1219/// slot, so the whole scrub stops there with nothing removed after it.
1220fn scrub_slot_entries(slot: &Path) -> Result<(), SlotQuarantine> {
1221    let unreadable = |source: std::io::Error| {
1222        SlotQuarantine::new(
1223            SlotRefusal::Enumeration,
1224            format!(
1225                "the entries of {} could not be listed: {:?}",
1226                slot.display(),
1227                source.kind()
1228            ),
1229        )
1230    };
1231    for entry in fs::read_dir(slot).map_err(unreadable)? {
1232        let name = entry.map_err(unreadable)?.file_name();
1233        let path = slot.join(&name);
1234        // Nothing is assumed from an entry that vanished: `verify_slot_scrubbed`
1235        // asks the filesystem again afterwards and refuses if it is still there.
1236        let Some(metadata) = listed_entry_metadata(&path).map_err(unreadable)? else {
1237            continue;
1238        };
1239        if is_work_folder(&name) {
1240            if is_retainable_work_folder(&name, &metadata) {
1241                continue;
1242            }
1243            return Err(SlotQuarantine::new(
1244                SlotRefusal::WorkNotADirectory,
1245                format!(
1246                    "the retained `{DEFAULT_WORK_FOLDER}` in {} is a link or a file rather than a \
1247                     real directory",
1248                    slot.display()
1249                ),
1250            ));
1251        }
1252        remove_slot_entry(&path, &metadata).map_err(|source| {
1253            SlotQuarantine::new(
1254                SlotRefusal::Deletion,
1255                format!(
1256                    "an entry of {} could not be removed: {:?}",
1257                    slot.display(),
1258                    source.kind()
1259                ),
1260            )
1261        })?;
1262    }
1263    Ok(())
1264}
1265
1266/// What a listed entry *is*, or `None` when it is no longer there.
1267///
1268/// An entry named by a listing and gone by the time it is stat-ed is absent,
1269/// which is a fact both passes over a slot want rather than an enumeration that
1270/// failed. Nothing is followed: `symlink_metadata` reports a link as a link.
1271fn listed_entry_metadata(path: &Path) -> std::io::Result<Option<fs::Metadata>> {
1272    match fs::symlink_metadata(path) {
1273        Ok(metadata) => Ok(Some(metadata)),
1274        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
1275        Err(source) => Err(source),
1276    }
1277}
1278
1279/// Remove one slot entry without following it.
1280///
1281/// An entry that is already gone is removed: the post-condition this serves is
1282/// "not there", and racing with whatever removed it first is not a refusal.
1283fn remove_slot_entry(path: &Path, metadata: &fs::Metadata) -> std::io::Result<()> {
1284    let removed = if is_link_like(metadata) {
1285        // A file symlink unlinks with `remove_file`; a directory symlink or a
1286        // Windows junction needs `remove_dir`. Neither follows the link.
1287        fs::remove_file(path).or_else(|_| fs::remove_dir(path))
1288    } else if metadata.is_dir() {
1289        remove_runtime_tree(path)
1290    } else {
1291        fs::remove_file(path)
1292    };
1293    match removed {
1294        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
1295        other => other,
1296    }
1297}
1298
1299/// Step 7: prove that nothing but the retained job workspace is left.
1300///
1301/// Two passes, deliberately independent, because the interesting failure is an
1302/// enumeration that under-reports. The first asks the directory what remains
1303/// and counts everything that is not a real `_work`. The second ignores the
1304/// listing entirely and stats the names an attempt is known to write — the
1305/// runner's binaries, the registration identity it stores beside them, the
1306/// process-identity sidecars and this agent's lifecycle marks — so a scrub that
1307/// silently skipped one is caught by a question that never consulted the
1308/// listing that skipped it.
1309///
1310/// Only the second pass names anything, and the encoded JIT handoff is reported
1311/// by its published prefix rather than by the UUID that follows it. A slot root
1312/// is workflow-writable, so a job that named a file after a secret would publish
1313/// it through any message that echoed the listing; the first pass therefore
1314/// reports a count.
1315fn verify_slot_scrubbed(slot: &Path) -> Result<(), SlotQuarantine> {
1316    let unreadable = |source: std::io::Error| {
1317        SlotQuarantine::new(
1318            SlotRefusal::Enumeration,
1319            format!(
1320                "the entries of {} could not be listed to verify the scrub: {:?}",
1321                slot.display(),
1322                source.kind()
1323            ),
1324        )
1325    };
1326    let mut residue = 0_usize;
1327    let mut named: Vec<String> = Vec::new();
1328    for entry in fs::read_dir(slot).map_err(unreadable)? {
1329        let name = entry.map_err(unreadable)?.file_name();
1330        // Absent is what this pass is here to establish. The named half below
1331        // stats every sensitive entry again, so an entry that only *looks*
1332        // absent to this listing is still caught.
1333        let Some(metadata) = listed_entry_metadata(&slot.join(&name)).map_err(unreadable)? else {
1334            continue;
1335        };
1336        if is_retainable_work_folder(&name, &metadata) {
1337            continue;
1338        }
1339        residue = residue.saturating_add(1);
1340        if name
1341            .to_string_lossy()
1342            .starts_with(RestrictiveHandoff::NAME_PREFIX)
1343        {
1344            named.push("an encoded JIT handoff".to_owned());
1345        }
1346    }
1347    named.extend(
1348        SENSITIVE_SLOT_ENTRIES
1349            .iter()
1350            .filter(|entry| fs::symlink_metadata(slot.join(entry)).is_ok())
1351            .map(|entry| format!("`{entry}`")),
1352    );
1353    if residue == 0 && named.is_empty() {
1354        return Ok(());
1355    }
1356    named.sort_unstable();
1357    named.dedup();
1358    Err(SlotQuarantine::new(
1359        SlotRefusal::Residue,
1360        residue_detail(slot, residue, &named),
1361    ))
1362}
1363
1364/// Word a [`SlotRefusal::Residue`] refusal from the two facts that produced it.
1365///
1366/// Separate from [`verify_slot_scrubbed`] because the disagreement it has to
1367/// report — a listing that counted nothing and a filesystem that answered
1368/// otherwise — is a race no test can stage, and a message that contradicts
1369/// itself is exactly what an operator reads at three in the morning.
1370///
1371/// `named` is the sanitized half: entries this crate published the names of.
1372/// A name a workflow chose is only ever counted, never echoed.
1373fn residue_detail(slot: &Path, residue: usize, named: &[String]) -> String {
1374    if residue == 0 {
1375        // The whole reason the second pass ignores the listing: the listing
1376        // reported a clean slot and the filesystem disagrees. Saying "0
1377        // entries survived" here would report the under-count as the fact.
1378        format!(
1379            "the listing of {} reported nothing but `{DEFAULT_WORK_FOLDER}`, yet {} survived \
1380                 cleanup",
1381            slot.display(),
1382            named.join(", ")
1383        )
1384    } else {
1385        format!(
1386            "{residue} entr{} other than `{DEFAULT_WORK_FOLDER}` survived cleanup of {}{}",
1387            if residue == 1 { "y" } else { "ies" },
1388            slot.display(),
1389            if named.is_empty() {
1390                String::new()
1391            } else {
1392                format!(", including {}", named.join(", "))
1393            }
1394        )
1395    }
1396}
1397
1398fn replacement_operation(outcome: &AttemptOutcome) -> Option<&'static str> {
1399    match outcome {
1400        AttemptOutcome::Failed {
1401            reason: FailureReason::JitExpired,
1402        } => Some("jit_expired_replacement"),
1403        AttemptOutcome::Failed {
1404            reason: FailureReason::ProcessExitedUnexpectedly,
1405        } => Some("exit_before_acceptance_replacement"),
1406        _ => None,
1407    }
1408}
1409
1410/// Lay the verified runner package out *around* whatever the slot retains.
1411///
1412/// `02-target-architecture.md`: "The verified runner package is copied into the
1413/// slot for the attempt", beside a `_work` that survives every attempt. Two
1414/// properties make that safe, and both are structural rather than documented:
1415///
1416/// * the walk is of the **source** tree, so a retained `_work` in the
1417///   destination is never opened, never descended into, and cannot be followed
1418///   wherever it might point;
1419/// * a top-level source entry named `_work` is refused rather than copied, so a
1420///   package that ever grew one could not merge itself into, or replace, the
1421///   job workspace of the attempt before it. The refusal is top-level only,
1422///   because the retained directory is a direct child of the slot; a `_work`
1423///   nested inside the package's own tree is an ordinary name.
1424///
1425/// On Unix the copy is a copy-on-write clone where the filesystem offers one,
1426/// taken from a [`package_seed`] on the destination's own volume. A plain
1427/// byte copy of the ~9,000-file package across volumes took four minutes on a
1428/// host whose runner root shared a busy disk with a build, and a queued job
1429/// waits for every second of it.
1430fn copy_package_tree(
1431    source: &Path,
1432    destination: &Path,
1433    seeds: &TrustedTrees,
1434) -> std::io::Result<()> {
1435    refuse_work_folder(source)?;
1436
1437    #[cfg(unix)]
1438    {
1439        // A seed that cannot be made is a slower launch, not a failed one.
1440        // The seed sits under the runner root, beside job runtimes, so the
1441        // refusal is re-checked on it; a seed that fails it is bypassed.
1442        let origin = package_seed(source, destination, seeds)
1443            .ok()
1444            .filter(|seed| refuse_work_folder(seed).is_ok())
1445            .unwrap_or_else(|| source.to_path_buf());
1446        clone_tree(&origin, destination)?;
1447        // A seed a job edited during the clone no longer holds; the caller
1448        // re-checks the cache entry itself.
1449        if origin != source && !seeds.holds(&origin) {
1450            return Err(std::io::Error::other(
1451                "the package seed changed while cloned",
1452            ));
1453        }
1454        Ok(())
1455    }
1456    #[cfg(not(unix))]
1457    {
1458        let _ = seeds;
1459        copy_package_entries(source, destination, true)
1460    }
1461}
1462
1463fn refuse_work_folder(source: &Path) -> std::io::Result<()> {
1464    if source.join(DEFAULT_WORK_FOLDER).exists() {
1465        return Err(std::io::Error::new(
1466            std::io::ErrorKind::InvalidData,
1467            "a cached runner package contains a _work folder, which means it was used \
1468             to run a job before it was archived; the cache must only contain clean \
1469             extracts to prevent data leakage",
1470        ));
1471    }
1472    Ok(())
1473}
1474
1475/// Where runtime copies of the package are cloned from, beside the runtimes.
1476#[cfg(unix)]
1477const PACKAGE_SEED_DIR: &str = ".runner-package";
1478
1479/// A copy of the cache entry on the destination's volume, so that every
1480/// runtime after the first is a same-volume clone rather than a cross-volume
1481/// byte copy. Returns `source` itself when it already shares the volume.
1482///
1483/// The seed lives at `<runner root>/.runner-package/<version>` and is replaced
1484/// whenever its manifest stops matching the cache entry's, which covers both a
1485/// new version and a reinstalled one. Superseded versions and interrupted
1486/// stagings are removed on that same pass. Launches hold the host allocation
1487/// lock for the whole materialization, so no other launch is cloning from a
1488/// seed while it is replaced.
1489///
1490/// The seed sits one `..` from every job's runtime and is owned by the account
1491/// jobs run as, so it is only reused while it is provably the tree this
1492/// process built: see [`TrustedTrees`].
1493///
1494/// Only on macOS, where the runner root is all but certainly APFS and a clone
1495/// is free. On a Linux filesystem without reflinks a seed would only move every
1496/// copy's reads onto the runner root's own, possibly busy, disk.
1497#[cfg(unix)]
1498fn package_seed(
1499    source: &Path,
1500    destination: &Path,
1501    seeds: &TrustedTrees,
1502) -> std::io::Result<PathBuf> {
1503    use std::os::unix::fs::MetadataExt as _;
1504
1505    if !cfg!(target_os = "macos") {
1506        return Ok(source.to_path_buf());
1507    }
1508    let root = destination
1509        .parent()
1510        .ok_or_else(|| std::io::Error::other("a runtime directory has no parent"))?;
1511    if fs::metadata(source)?.dev() == fs::metadata(root)?.dev() {
1512        return Ok(source.to_path_buf());
1513    }
1514    refresh_package_seed(source, &root.join(PACKAGE_SEED_DIR), seeds)
1515}
1516
1517/// The package trees this process built or installed, and what each looked
1518/// like right after: the cache entry runtimes are copied from and the seeds
1519/// they are cloned from.
1520///
1521/// Both are owned by the account jobs run as, so a job can edit any file in
1522/// them, and nothing on disk can say what they should hold: the published
1523/// digest covers the archive, not the extracted tree, and a record kept on disk
1524/// could be rewritten by the same account. So a tree is trusted only while its
1525/// [`tree_fingerprint`] matches the one recorded here. Every edit, replacement
1526/// or rename moves the change time (`ctime`) of the entry or of its directory,
1527/// and only the superuser can set a change time. A tree without a record, as
1528/// every tree has after a restart, is rebuilt before it is used.
1529///
1530/// Unix only. On Windows every tree is trusted, as before.
1531#[derive(Debug, Default)]
1532pub(crate) struct TrustedTrees {
1533    #[cfg_attr(not(unix), allow(dead_code))]
1534    prints: Mutex<std::collections::HashMap<PathBuf, [u8; 32]>>,
1535}
1536
1537impl TrustedTrees {
1538    /// Whether `root` is a tree recorded here and still looks as it did.
1539    fn holds(&self, root: &Path) -> bool {
1540        #[cfg(unix)]
1541        {
1542            let recorded = self
1543                .prints
1544                .lock()
1545                .unwrap_or_else(std::sync::PoisonError::into_inner)
1546                .get(root)
1547                .copied();
1548            recorded.is_some_and(|recorded| {
1549                tree_fingerprint(root).is_ok_and(|current| current == recorded)
1550            })
1551        }
1552        #[cfg(not(unix))]
1553        {
1554            let _ = root;
1555            true
1556        }
1557    }
1558
1559    /// Trust `root` as it is now; called right after this process made it.
1560    fn record(&self, root: &Path) -> std::io::Result<()> {
1561        #[cfg(unix)]
1562        {
1563            let print = tree_fingerprint(root)?;
1564            self.prints
1565                .lock()
1566                .unwrap_or_else(std::sync::PoisonError::into_inner)
1567                .insert(root.to_path_buf(), print);
1568        }
1569        #[cfg(not(unix))]
1570        let _ = root;
1571        Ok(())
1572    }
1573}
1574
1575/// A digest of every entry under `root`: its relative path, inode, size, mode,
1576/// owner and change time.
1577#[cfg(unix)]
1578fn tree_fingerprint(root: &Path) -> std::io::Result<[u8; 32]> {
1579    use sha2::{Digest as _, Sha256};
1580    use std::os::unix::fs::MetadataExt as _;
1581
1582    fn walk(root: &Path, path: &Path, hasher: &mut Sha256) -> std::io::Result<()> {
1583        let metadata = fs::symlink_metadata(path)?;
1584        let relative = path.strip_prefix(root).unwrap_or(path);
1585        hasher.update(relative.as_os_str().as_encoded_bytes());
1586        hasher.update([0]);
1587        for field in [
1588            metadata.ino(),
1589            metadata.size(),
1590            u64::from(metadata.mode()),
1591            u64::from(metadata.uid()),
1592            u64::from(metadata.gid()),
1593        ] {
1594            hasher.update(field.to_le_bytes());
1595        }
1596        hasher.update(metadata.ctime().to_le_bytes());
1597        hasher.update(metadata.ctime_nsec().to_le_bytes());
1598        if metadata.is_dir() {
1599            let mut children = fs::read_dir(path)?
1600                .map(|entry| entry.map(|entry| entry.path()))
1601                .collect::<std::io::Result<Vec<_>>>()?;
1602            children.sort();
1603            for child in children {
1604                walk(root, &child, hasher)?;
1605            }
1606        }
1607        Ok(())
1608    }
1609
1610    let mut hasher = Sha256::new();
1611    walk(root, root, &mut hasher)?;
1612    Ok(hasher.finalize().into())
1613}
1614
1615/// The seed of `source` under `seeds`, rebuilt unless its manifest matches the
1616/// cache entry's and its fingerprint the one this process recorded.
1617#[cfg(unix)]
1618fn refresh_package_seed(
1619    source: &Path,
1620    seeds: &Path,
1621    trusted: &TrustedTrees,
1622) -> std::io::Result<PathBuf> {
1623    use crate::package::MANIFEST_FILE;
1624
1625    let version = source
1626        .file_name()
1627        .ok_or_else(|| std::io::Error::other("a cache entry has no name"))?;
1628    let seed = seeds.join(version);
1629    let manifest = fs::read(source.join(MANIFEST_FILE))?;
1630    if fs::read(seed.join(MANIFEST_FILE)).is_ok_and(|held| held == manifest) && trusted.holds(&seed)
1631    {
1632        return Ok(seed);
1633    }
1634
1635    // Superseded versions, a stale seed and interrupted stagings all go.
1636    match remove_runtime_tree(seeds) {
1637        Err(error) if error.kind() != std::io::ErrorKind::NotFound => return Err(error),
1638        _ => {}
1639    }
1640    fs::create_dir_all(seeds)?;
1641    let staging = seeds.join(format!(".staging-{}", uuid::Uuid::new_v4()));
1642    fs::create_dir(&staging)?;
1643    // A half-made staging would hold the disk space the fallback copy needs.
1644    if let Err(error) = clone_tree(source, &staging).and_then(|()| fs::rename(&staging, &seed)) {
1645        let _ = remove_runtime_tree(&staging);
1646        return Err(error);
1647    }
1648    // After the rename, which itself moves the seed directory's change time.
1649    trusted.record(&seed)?;
1650    Ok(seed)
1651}
1652
1653/// `cp -a`, asking for a copy-on-write clone where the platform's `cp` can.
1654///
1655/// macOS `cp -c` and GNU `--reflink=auto` both fall back to a byte copy across
1656/// volumes; a `cp` that does not know the flag at all gets a plain `cp -a`.
1657#[cfg(unix)]
1658fn clone_tree(source: &Path, destination: &Path) -> std::io::Result<()> {
1659    #[cfg(target_os = "macos")]
1660    const CLONE: &[&str] = &["-c"];
1661    #[cfg(target_os = "linux")]
1662    const CLONE: &[&str] = &["--reflink=auto"];
1663    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
1664    const CLONE: &[&str] = &[];
1665
1666    let from = format!("{}/.", source.display());
1667    let cp = |extra: &[&str]| {
1668        std::process::Command::new("cp")
1669            .arg("-a")
1670            .args(extra)
1671            .arg(&from)
1672            .arg(destination)
1673            .status()
1674    };
1675    if cp(CLONE)?.success() || cp(&[])?.success() {
1676        Ok(())
1677    } else {
1678        Err(std::io::Error::other("cp failed"))
1679    }
1680}
1681
1682#[cfg(not(unix))]
1683fn copy_package_entries(source: &Path, destination: &Path, top_level: bool) -> std::io::Result<()> {
1684    fs::create_dir_all(destination)?;
1685    for entry in fs::read_dir(source)? {
1686        let entry = entry?;
1687        if top_level && is_work_folder(&entry.file_name()) {
1688            return Err(std::io::Error::new(
1689                std::io::ErrorKind::InvalidData,
1690                format!(
1691                    "the runner package holds a top-level `{DEFAULT_WORK_FOLDER}`; copying \
1692                     it would overwrite the job workspace a persistent slot retains"
1693                ),
1694            ));
1695        }
1696        let target = destination.join(entry.file_name());
1697        if entry.file_type()?.is_dir() {
1698            copy_package_entries(&entry.path(), &target, false)?;
1699        } else {
1700            fs::copy(entry.path(), target)?;
1701        }
1702    }
1703    Ok(())
1704}
1705
1706/// Process operations are attempt-addressed so a recovered process and a child
1707/// started in this invocation are supervised through one port.
1708#[derive(Debug, Clone, PartialEq, Eq)]
1709pub struct ProcessStartFailure {
1710    pub reason: FailureReason,
1711    /// False once a child existed: the one-shot JIT value may have been
1712    /// consumed, so retrying it could start a duplicate.
1713    pub retryable: bool,
1714    /// Set only when cleanup could not prove the spawned process dead.  The
1715    /// caller must journal `starting` and retain capacity/supervision.
1716    pub live_pid: Option<u32>,
1717}
1718
1719impl ProcessStartFailure {
1720    pub(crate) fn before_spawn(reason: FailureReason) -> Self {
1721        Self {
1722            reason,
1723            retryable: true,
1724            live_pid: None,
1725        }
1726    }
1727
1728    fn after_spawn_stopped() -> Self {
1729        Self {
1730            reason: FailureReason::ProcessStartFailed,
1731            retryable: false,
1732            live_pid: None,
1733        }
1734    }
1735
1736    fn after_spawn_live(pid: u32) -> Self {
1737        Self::after_spawn_live_with_reason(pid, FailureReason::ProcessStartFailed)
1738    }
1739
1740    fn after_spawn_live_with_reason(pid: u32, reason: FailureReason) -> Self {
1741        Self {
1742            reason,
1743            retryable: false,
1744            live_pid: Some(pid),
1745        }
1746    }
1747}
1748
1749/// A credential-free capability report. An isolated policy never treats a
1750/// native-ready result as permission to launch.
1751#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1752pub enum ProviderCapability {
1753    Ready,
1754    Unsupported,
1755    NotInstalled,
1756    PermissionDenied,
1757    ImageUnavailableOrIncompatible,
1758    DiskQuotaUnavailable,
1759    Degraded,
1760}
1761
1762impl ProviderCapability {
1763    #[must_use]
1764    pub const fn display_name(self) -> &'static str {
1765        match self {
1766            Self::Ready => "ready",
1767            Self::Unsupported => "unsupported",
1768            Self::NotInstalled => "not installed",
1769            Self::PermissionDenied => "permission denied",
1770            Self::ImageUnavailableOrIncompatible => "image unavailable or incompatible",
1771            Self::DiskQuotaUnavailable => "disk quota unavailable",
1772            Self::Degraded => "degraded",
1773        }
1774    }
1775
1776    pub(crate) fn refusal(self) -> FailureReason {
1777        FailureReason::IsolationProvider(match self {
1778            Self::Ready | Self::Degraded => IsolationProviderFailure::Degraded,
1779            Self::Unsupported => IsolationProviderFailure::Unsupported,
1780            Self::NotInstalled => IsolationProviderFailure::NotInstalled,
1781            Self::PermissionDenied => IsolationProviderFailure::PermissionDenied,
1782            Self::ImageUnavailableOrIncompatible => {
1783                IsolationProviderFailure::ImageUnavailableOrIncompatible
1784            }
1785            Self::DiskQuotaUnavailable => IsolationProviderFailure::DiskQuotaUnavailable,
1786        })
1787    }
1788}
1789
1790/// Only the closed isolation category may cross from an adapter into the
1791/// attempt journal or operator error. Arbitrary adapter text could echo JIT.
1792fn safe_isolation_reason(
1793    reason: FailureReason,
1794    fallback: IsolationProviderFailure,
1795) -> FailureReason {
1796    match reason {
1797        FailureReason::IsolationProvider(_) => reason,
1798        _ => FailureReason::IsolationProvider(fallback),
1799    }
1800}
1801
1802#[derive(Debug, Clone, PartialEq, Eq)]
1803pub struct PreparedEnvironment {
1804    attempt: AttemptId,
1805    identity: Option<EnvironmentIdentity>,
1806}
1807
1808impl PreparedEnvironment {
1809    /// An adapter must return the identity independently recorded on its resource.
1810    #[must_use]
1811    pub fn isolated(attempt: AttemptId, identity: EnvironmentIdentity) -> Self {
1812        Self {
1813            attempt,
1814            identity: Some(identity),
1815        }
1816    }
1817
1818    #[must_use]
1819    pub fn identity(&self) -> Option<&EnvironmentIdentity> {
1820        self.identity.as_ref()
1821    }
1822}
1823
1824#[derive(Debug, Clone, PartialEq, Eq)]
1825pub struct ResolvedEnvironment {
1826    pub provider_kind: Backend,
1827    pub image: ImageReference,
1828}
1829
1830/// Closed, non-secret provider diagnostics; adapters cannot attach arbitrary output.
1831#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1832pub enum ProviderDiagnostic {
1833    CapabilityUnavailable,
1834    ImageRejected,
1835    PrepareFailed,
1836    StartFailed,
1837    OwnershipMismatch,
1838    CleanupDeferred,
1839}
1840
1841#[derive(Debug, Clone, PartialEq, Eq)]
1842pub enum EnvironmentIdentity {
1843    NativeProcess(u32),
1844    Isolated {
1845        host: HostId,
1846        attempt: AttemptId,
1847        provider_kind: Backend,
1848        environment_id: String,
1849        resolved_image: ImageReference,
1850        generation: String,
1851    },
1852}
1853
1854#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1855pub enum EnvironmentState {
1856    Starting,
1857    Running,
1858    Exited,
1859    Missing,
1860}
1861
1862#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1863pub enum ProviderStop {
1864    Stopped,
1865    StillRunning,
1866}
1867
1868#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1869pub enum ProviderDestroy {
1870    Destroyed,
1871    Deferred(&'static str),
1872}
1873
1874fn identity_matches_intent(
1875    host: HostId,
1876    attempt: &RunnerAttempt,
1877    identity: &EnvironmentIdentity,
1878) -> bool {
1879    identity_same_allocation(host, attempt, identity)
1880        && match (attempt.execution(), identity) {
1881            (
1882                AttemptExecution::Isolated { environment_id, .. },
1883                EnvironmentIdentity::Isolated {
1884                    environment_id: id, ..
1885                },
1886            ) => environment_id
1887                .as_ref()
1888                .is_none_or(|journal_id| journal_id == id),
1889            _ => false,
1890        }
1891}
1892
1893fn identity_same_allocation(
1894    host: HostId,
1895    attempt: &RunnerAttempt,
1896    identity: &EnvironmentIdentity,
1897) -> bool {
1898    match (attempt.execution(), identity) {
1899        (
1900            AttemptExecution::Isolated {
1901                provider_kind,
1902                environment_id: _,
1903                resolved_image,
1904                generation,
1905            },
1906            EnvironmentIdentity::Isolated {
1907                host: owner,
1908                attempt: owner_attempt,
1909                provider_kind: kind,
1910                environment_id: _,
1911                resolved_image: image,
1912                generation: resource_generation,
1913            },
1914        ) => {
1915            *owner == host
1916                && *owner_attempt == attempt.id
1917                && provider_kind == kind
1918                && resolved_image == image
1919                && generation == resource_generation
1920        }
1921        _ => false,
1922    }
1923}
1924
1925/// The JIT value can be passed into a provider start exactly once. It must never
1926/// become a provider identity, label, argument, persisted configuration or log field.
1927pub struct OneTimeJitHandoff<'a> {
1928    config: &'a EncodedJitConfig,
1929}
1930
1931impl fmt::Debug for OneTimeJitHandoff<'_> {
1932    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1933        f.write_str("OneTimeJitHandoff([redacted])")
1934    }
1935}
1936
1937impl<'a> OneTimeJitHandoff<'a> {
1938    pub(crate) fn new(config: &'a EncodedJitConfig) -> Self {
1939        Self { config }
1940    }
1941
1942    /// Consuming the handoff prevents the same token being delivered twice by one call.
1943    pub fn consume(self) -> &'a EncodedJitConfig {
1944        self.config
1945    }
1946}
1947
1948/// Provider lifecycle and native process supervision. The default lifecycle
1949/// methods adapt the existing native spawn/recovery implementation, and refuse
1950/// every isolated allocation. Future platform adapters must implement their
1951/// own prepare/start/inspect/stop/destroy paths rather than fall back here.
1952pub trait ExecutionProvider: fmt::Debug + Send + Sync {
1953    fn probe(&self, policy: &ScalePolicy) -> ProviderCapability {
1954        if policy.execution_policy().is_native() {
1955            ProviderCapability::Ready
1956        } else {
1957            ProviderCapability::Unsupported
1958        }
1959    }
1960
1961    /// Resolve Auto and image identity before allocation or GitHub JIT. Native has no image.
1962    fn resolve(&self, policy: &ScalePolicy) -> Result<Option<ResolvedEnvironment>, FailureReason> {
1963        if policy.execution_policy().is_native() {
1964            Ok(None)
1965        } else {
1966            Err(FailureReason::Other(
1967                "execution provider unavailable".into(),
1968            ))
1969        }
1970    }
1971
1972    fn prepare(
1973        &self,
1974        attempt: &RunnerAttempt,
1975        policy: &ScalePolicy,
1976    ) -> Result<PreparedEnvironment, FailureReason> {
1977        if !attempt.execution().is_native() || !policy.execution_policy().is_native() {
1978            return Err(FailureReason::Other(
1979                "execution provider unavailable".into(),
1980            ));
1981        }
1982        Ok(PreparedEnvironment {
1983            attempt: attempt.id,
1984            identity: None,
1985        })
1986    }
1987
1988    fn start(
1989        &self,
1990        prepared: PreparedEnvironment,
1991        attempt: &RunnerAttempt,
1992        handoff: OneTimeJitHandoff<'_>,
1993    ) -> Result<EnvironmentIdentity, ProcessStartFailure> {
1994        if prepared.attempt != attempt.id || !attempt.execution().is_native() {
1995            return Err(ProcessStartFailure::before_spawn(FailureReason::Other(
1996                "execution provider identity mismatch".into(),
1997            )));
1998        }
1999        self.spawn(attempt, handoff.consume())
2000            .map(EnvironmentIdentity::NativeProcess)
2001    }
2002
2003    fn inspect(&self, attempt: &RunnerAttempt) -> Result<EnvironmentState, FailureReason> {
2004        if !attempt.execution().is_native() {
2005            return Err(FailureReason::Other(
2006                "execution provider unavailable".into(),
2007            ));
2008        }
2009        self.is_alive(attempt).map(|alive| {
2010            if alive {
2011                EnvironmentState::Running
2012            } else {
2013                EnvironmentState::Missing
2014            }
2015        })
2016    }
2017
2018    fn stop(&self, attempt: &RunnerAttempt) -> Result<ProviderStop, FailureReason> {
2019        if !attempt.execution().is_native() {
2020            return Err(FailureReason::Other(
2021                "execution provider unavailable".into(),
2022            ));
2023        }
2024        self.terminate(attempt).map(|()| ProviderStop::Stopped)
2025    }
2026
2027    fn destroy(&self, attempt: &RunnerAttempt) -> Result<ProviderDestroy, FailureReason> {
2028        if !attempt.execution().is_native() {
2029            return Err(FailureReason::Other(
2030                "execution provider unavailable".into(),
2031            ));
2032        }
2033        // Native termination is already settled by the existing recovery
2034        // decision before workspace cleanup. There is no separate sandbox
2035        // resource to destroy, and repeating an observation here changes the
2036        // established native cleanup behaviour.
2037        Ok(ProviderDestroy::Destroyed)
2038    }
2039
2040    fn recover(&self, attempt: &RunnerAttempt) -> Result<EnvironmentState, FailureReason> {
2041        self.inspect(attempt)
2042    }
2043
2044    fn enumerate_owned(&self, _host_id: HostId) -> Vec<EnvironmentIdentity> {
2045        Vec::new() // Native processes have no independent provider resources.
2046    }
2047
2048    fn diagnostics(&self, _attempt: &RunnerAttempt) -> Vec<ProviderDiagnostic> {
2049        Vec::new() // No job or JIT output enters provider metadata.
2050    }
2051
2052    /// Independent resource metadata must match the journal before destructive calls.
2053    fn owns(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
2054        Ok(attempt.execution().is_native())
2055    }
2056
2057    fn spawn(
2058        &self,
2059        attempt: &RunnerAttempt,
2060        config: &EncodedJitConfig,
2061    ) -> Result<u32, ProcessStartFailure>;
2062    fn is_alive(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason>;
2063    /// Durable identity observed for a process that spawned before the
2064    /// `starting` journal write survived.
2065    fn recovered_pid(&self, attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason>;
2066    /// True only for a child this invocation owned and reaped with a successful
2067    /// exit status.  A recovered process that is merely gone answers false.
2068    fn completed_successfully(&self, attempt: &RunnerAttempt) -> bool;
2069    fn record_terminate_intent(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason>;
2070    fn has_terminate_intent(&self, attempt: &RunnerAttempt) -> bool;
2071    fn terminate(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason>;
2072}
2073
2074/// Compatibility name for existing native process tests and callers.
2075pub use ExecutionProvider as ProcessSupervisor;
2076
2077/// Native process supervision.  The start token is stored beside the runtime;
2078/// recovery never trusts a recycled PID merely because SQLite contains it.
2079#[derive(Debug, Default)]
2080pub struct NativeProcesses {
2081    children: Mutex<BTreeMap<AttemptId, ChildProcess>>,
2082    successful_exits: Mutex<BTreeMap<AttemptId, bool>>,
2083    /// The host's `runner.env`, read at every launch so that `host env set`
2084    /// reaches the next runner without a daemon restart. `None` applies none.
2085    runner_env_file: Option<PathBuf>,
2086    #[cfg(test)]
2087    post_spawn_faults: Mutex<VecDeque<PostSpawnBoundary>>,
2088    #[cfg(test)]
2089    post_spawn_reaps: std::sync::atomic::AtomicUsize,
2090    #[cfg(test)]
2091    post_spawn_stop_failures: std::sync::atomic::AtomicUsize,
2092    #[cfg(test)]
2093    use_long_lived_test_listener: std::sync::atomic::AtomicBool,
2094}
2095
2096/// Production provider set. Native and isolated allocations are routed by the
2097/// immutable execution kind in the policy/journal; an unavailable isolated
2098/// backend can never fall through to native process launch.
2099#[derive(Debug)]
2100pub struct PlatformExecutionProvider {
2101    native: NativeProcesses,
2102    oci: OciProcesses,
2103    windows_hyper_v: WindowsHyperVContainers,
2104}
2105
2106impl PlatformExecutionProvider {
2107    #[must_use]
2108    pub fn new(host_id: HostId) -> Self {
2109        Self {
2110            native: NativeProcesses::new(),
2111            oci: OciProcesses::new(host_id),
2112            windows_hyper_v: WindowsHyperVContainers::new(host_id),
2113        }
2114    }
2115
2116    /// Applies the host's `runner.env` at `path` to every native runner.
2117    /// Isolated runners do not get it: their environment is the image's.
2118    #[must_use]
2119    pub fn with_runner_env_file(mut self, path: PathBuf) -> Self {
2120        self.native = self.native.with_runner_env_file(path);
2121        self
2122    }
2123
2124    fn isolated_provider(&self, backend: Backend) -> Option<&dyn ExecutionProvider> {
2125        match backend {
2126            Backend::Auto => {
2127                #[cfg(target_os = "windows")]
2128                {
2129                    Some(&self.windows_hyper_v)
2130                }
2131                #[cfg(not(target_os = "windows"))]
2132                {
2133                    Some(&self.oci)
2134                }
2135            }
2136            Backend::Oci => Some(&self.oci),
2137            Backend::WindowsHyperVContainer => Some(&self.windows_hyper_v),
2138            Backend::VirtualMachine => None,
2139        }
2140    }
2141
2142    fn provider_for_attempt(&self, attempt: &RunnerAttempt) -> Option<&dyn ExecutionProvider> {
2143        match attempt.execution() {
2144            AttemptExecution::Native { .. } => Some(&self.native),
2145            AttemptExecution::Isolated { provider_kind, .. } => {
2146                self.isolated_provider(*provider_kind)
2147            }
2148        }
2149    }
2150
2151    fn required_provider_for_attempt(
2152        &self,
2153        attempt: &RunnerAttempt,
2154    ) -> Result<&dyn ExecutionProvider, FailureReason> {
2155        self.provider_for_attempt(attempt)
2156            .ok_or_else(|| FailureReason::Other("execution provider unavailable".into()))
2157    }
2158}
2159
2160impl ExecutionProvider for PlatformExecutionProvider {
2161    fn probe(&self, policy: &ScalePolicy) -> ProviderCapability {
2162        match policy.execution_policy() {
2163            runner_manager_domain::execution::ExecutionPolicy::Native => self.native.probe(policy),
2164            runner_manager_domain::execution::ExecutionPolicy::Isolated { backend, .. } => self
2165                .isolated_provider(*backend)
2166                .map_or(ProviderCapability::Unsupported, |provider| {
2167                    provider.probe(policy)
2168                }),
2169        }
2170    }
2171
2172    fn resolve(&self, policy: &ScalePolicy) -> Result<Option<ResolvedEnvironment>, FailureReason> {
2173        match policy.execution_policy() {
2174            runner_manager_domain::execution::ExecutionPolicy::Native => {
2175                self.native.resolve(policy)
2176            }
2177            runner_manager_domain::execution::ExecutionPolicy::Isolated { backend, .. } => self
2178                .isolated_provider(*backend)
2179                .ok_or_else(|| FailureReason::Other("execution provider unavailable".into()))?
2180                .resolve(policy),
2181        }
2182    }
2183
2184    fn prepare(
2185        &self,
2186        attempt: &RunnerAttempt,
2187        policy: &ScalePolicy,
2188    ) -> Result<PreparedEnvironment, FailureReason> {
2189        match policy.execution_policy() {
2190            runner_manager_domain::execution::ExecutionPolicy::Native => {
2191                self.native.prepare(attempt, policy)
2192            }
2193            runner_manager_domain::execution::ExecutionPolicy::Isolated { backend, .. } => self
2194                .isolated_provider(*backend)
2195                .ok_or_else(|| FailureReason::Other("execution provider unavailable".into()))?
2196                .prepare(attempt, policy),
2197        }
2198    }
2199
2200    fn start(
2201        &self,
2202        prepared: PreparedEnvironment,
2203        attempt: &RunnerAttempt,
2204        handoff: OneTimeJitHandoff<'_>,
2205    ) -> Result<EnvironmentIdentity, ProcessStartFailure> {
2206        self.provider_for_attempt(attempt)
2207            .ok_or_else(|| {
2208                ProcessStartFailure::before_spawn(FailureReason::Other(
2209                    "execution provider unavailable".into(),
2210                ))
2211            })?
2212            .start(prepared, attempt, handoff)
2213    }
2214
2215    fn inspect(&self, attempt: &RunnerAttempt) -> Result<EnvironmentState, FailureReason> {
2216        self.required_provider_for_attempt(attempt)?
2217            .inspect(attempt)
2218    }
2219
2220    fn stop(&self, attempt: &RunnerAttempt) -> Result<ProviderStop, FailureReason> {
2221        self.required_provider_for_attempt(attempt)?.stop(attempt)
2222    }
2223
2224    fn destroy(&self, attempt: &RunnerAttempt) -> Result<ProviderDestroy, FailureReason> {
2225        self.required_provider_for_attempt(attempt)?
2226            .destroy(attempt)
2227    }
2228
2229    fn recover(&self, attempt: &RunnerAttempt) -> Result<EnvironmentState, FailureReason> {
2230        self.required_provider_for_attempt(attempt)?
2231            .recover(attempt)
2232    }
2233
2234    fn enumerate_owned(&self, host_id: HostId) -> Vec<EnvironmentIdentity> {
2235        let mut owned = self.oci.enumerate_owned(host_id);
2236        owned.extend(self.windows_hyper_v.enumerate_owned(host_id));
2237        owned
2238    }
2239
2240    fn diagnostics(&self, attempt: &RunnerAttempt) -> Vec<ProviderDiagnostic> {
2241        self.provider_for_attempt(attempt)
2242            .map_or_else(Vec::new, |provider| provider.diagnostics(attempt))
2243    }
2244
2245    fn owns(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
2246        self.required_provider_for_attempt(attempt)?.owns(attempt)
2247    }
2248
2249    fn spawn(
2250        &self,
2251        attempt: &RunnerAttempt,
2252        config: &EncodedJitConfig,
2253    ) -> Result<u32, ProcessStartFailure> {
2254        if !attempt.execution().is_native() {
2255            return Err(ProcessStartFailure::before_spawn(FailureReason::Other(
2256                "native process launch refused for an isolated allocation".into(),
2257            )));
2258        }
2259        self.native.spawn(attempt, config)
2260    }
2261
2262    fn is_alive(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
2263        self.required_provider_for_attempt(attempt)?
2264            .is_alive(attempt)
2265    }
2266
2267    fn recovered_pid(&self, attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason> {
2268        self.required_provider_for_attempt(attempt)?
2269            .recovered_pid(attempt)
2270    }
2271
2272    fn completed_successfully(&self, attempt: &RunnerAttempt) -> bool {
2273        self.provider_for_attempt(attempt)
2274            .is_some_and(|provider| provider.completed_successfully(attempt))
2275    }
2276
2277    fn record_terminate_intent(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason> {
2278        self.required_provider_for_attempt(attempt)?
2279            .record_terminate_intent(attempt)
2280    }
2281
2282    fn has_terminate_intent(&self, attempt: &RunnerAttempt) -> bool {
2283        self.provider_for_attempt(attempt)
2284            .is_some_and(|provider| provider.has_terminate_intent(attempt))
2285    }
2286
2287    fn terminate(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason> {
2288        self.required_provider_for_attempt(attempt)?
2289            .terminate(attempt)
2290    }
2291}
2292
2293#[cfg(test)]
2294#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2295enum PostSpawnBoundary {
2296    HandoffDelete,
2297    IdentitySerialize,
2298    IdentityWrite,
2299    ChildMapInsert,
2300}
2301
2302impl NativeProcesses {
2303    #[must_use]
2304    pub fn new() -> Self {
2305        Self::default()
2306    }
2307
2308    /// Applies the host's `runner.env` at `path` to every runner launched.
2309    #[must_use]
2310    pub fn with_runner_env_file(mut self, path: PathBuf) -> Self {
2311        self.runner_env_file = Some(path);
2312        self
2313    }
2314
2315    /// The host's `runner.env`, read now.
2316    ///
2317    /// A file that cannot be read or parsed fails the launch rather than
2318    /// starting a runner without the environment the operator configured.
2319    /// [`ExecutionProvider::prepare`] asks first, before a JIT runner is
2320    /// registered, so a broken file costs no registration. The reason names
2321    /// the file and the line or variable, never a value.
2322    fn host_env(&self) -> Result<RunnerEnv, FailureReason> {
2323        let Some(path) = &self.runner_env_file else {
2324            return Ok(RunnerEnv::default());
2325        };
2326        RunnerEnv::load(path).map_err(|error| {
2327            tracing::warn!(
2328                reason = "runner_env_file_invalid",
2329                "runner.env could not be applied, so no runner starts until it is fixed: {error}"
2330            );
2331            FailureReason::Other(format!(
2332                "runner.env cannot be applied ({error}); fix it with `runner-manager host env`"
2333            ))
2334        })
2335    }
2336
2337    #[cfg(test)]
2338    fn fail_post_spawn_at(&self, boundary: PostSpawnBoundary) {
2339        self.post_spawn_faults.lock().unwrap().push_back(boundary);
2340    }
2341
2342    #[cfg(test)]
2343    fn faults_at(&self, boundary: PostSpawnBoundary) -> bool {
2344        let mut faults = self.post_spawn_faults.lock().unwrap();
2345        if faults.front() == Some(&boundary) {
2346            faults.pop_front();
2347            true
2348        } else {
2349            false
2350        }
2351    }
2352
2353    #[cfg(test)]
2354    fn fail_post_spawn_stops(&self, count: usize) {
2355        self.post_spawn_stop_failures
2356            .fetch_add(count, std::sync::atomic::Ordering::SeqCst);
2357    }
2358
2359    #[cfg(test)]
2360    fn fail_next_post_spawn_stop(&self) {
2361        self.fail_post_spawn_stops(1);
2362    }
2363
2364    #[cfg(test)]
2365    fn use_long_lived_test_listener(&self) {
2366        self.use_long_lived_test_listener
2367            .store(true, std::sync::atomic::Ordering::SeqCst);
2368    }
2369
2370    fn stop_spawned_child(&self, child: &mut ChildProcess) -> Result<(), FailureReason> {
2371        #[cfg(test)]
2372        if self
2373            .post_spawn_stop_failures
2374            .fetch_update(
2375                std::sync::atomic::Ordering::SeqCst,
2376                std::sync::atomic::Ordering::SeqCst,
2377                |left| if left > 0 { Some(left - 1) } else { None },
2378            )
2379            .is_ok()
2380        {
2381            return Err(FailureReason::Other("injected runner stop failure".into()));
2382        }
2383        child
2384            .stop(Duration::from_secs(1))
2385            .map(|_| ())
2386            .map_err(|_| FailureReason::Other("spawned runner process could not be stopped".into()))
2387    }
2388
2389    fn abort_spawned_child(
2390        &self,
2391        mut child: ChildProcess,
2392        attempt: &RunnerAttempt,
2393        remove_identity: bool,
2394    ) -> ProcessStartFailure {
2395        let mut reaped = self.stop_spawned_child(&mut child).is_ok();
2396        if reaped {
2397            if remove_identity {
2398                Self::remove_identity_files(attempt);
2399            }
2400        } else {
2401            // A failed stop is not a failed attempt yet.  Persist enough truth
2402            // for crash recovery, and retain the owned child when possible.
2403            let identity_durable =
2404                serde_json::to_vec(child.identity())
2405                    .ok()
2406                    .is_some_and(|identity| {
2407                        self.persist_identity(attempt, &identity).is_ok()
2408                            || self.persist_fallback_identity(attempt, &identity).is_ok()
2409                    });
2410            if !identity_durable {
2411                // Returning a live PID as though recovery were complete would
2412                // make the next boot trust a recyclable PID. Reaping is bounded;
2413                // if it cannot finish, the durable `starting` journal entry is
2414                // deliberately unresolved on restart and blocks new launches.
2415                for _ in 1..MAX_POST_SPAWN_STOP_ATTEMPTS {
2416                    if self.stop_spawned_child(&mut child).is_ok() {
2417                        reaped = true;
2418                        break;
2419                    }
2420                }
2421                if !reaped {
2422                    // The attempt journal will durably record `starting` and
2423                    // its PID. Recovery treats a missing full identity as
2424                    // unresolved and starts nothing, so bounded stop failure
2425                    // cannot turn into either a hang or a duplicate runner.
2426                    let pid = child.pid();
2427                    let marker = write_durable_file(
2428                        &Self::unresolved_process_path(attempt),
2429                        pid.to_string().as_bytes(),
2430                    );
2431                    self.children
2432                        .lock()
2433                        .unwrap_or_else(std::sync::PoisonError::into_inner)
2434                        .insert(attempt.id, child);
2435                    let reason = if marker.is_ok() {
2436                        FailureReason::Other(
2437                            "spawn cleanup exhausted its bounded stop attempts; the live process remains under durable unresolved supervision"
2438                                .into(),
2439                        )
2440                    } else {
2441                        FailureReason::Other(
2442                            "spawn cleanup exhausted its bounded stop attempts and the unresolved-process marker could not be journalled"
2443                                .into(),
2444                        )
2445                    };
2446                    return ProcessStartFailure::after_spawn_live_with_reason(pid, reason);
2447                }
2448            } else {
2449                let pid = child.pid();
2450                self.children
2451                    .lock()
2452                    .unwrap_or_else(std::sync::PoisonError::into_inner)
2453                    .insert(attempt.id, child);
2454                return ProcessStartFailure::after_spawn_live(pid);
2455            }
2456        }
2457        #[cfg(test)]
2458        if reaped {
2459            self.post_spawn_reaps
2460                .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2461        }
2462        #[cfg(not(test))]
2463        let _ = reaped;
2464        ProcessStartFailure::after_spawn_stopped()
2465    }
2466
2467    fn identity_path(attempt: &RunnerAttempt) -> PathBuf {
2468        attempt.runtime_path().join(IDENTITY_FILE)
2469    }
2470
2471    fn fallback_identity_path(attempt: &RunnerAttempt) -> PathBuf {
2472        attempt.runtime_path().join(FALLBACK_IDENTITY_FILE)
2473    }
2474
2475    fn unresolved_process_path(attempt: &RunnerAttempt) -> PathBuf {
2476        attempt.runtime_path().join(UNRESOLVED_PROCESS_FILE)
2477    }
2478
2479    fn remove_identity_files(attempt: &RunnerAttempt) {
2480        let _ = fs::remove_file(Self::identity_path(attempt));
2481        let _ = fs::remove_file(Self::fallback_identity_path(attempt));
2482        let _ = fs::remove_file(Self::unresolved_process_path(attempt));
2483    }
2484
2485    fn persist_identity(&self, attempt: &RunnerAttempt, bytes: &[u8]) -> std::io::Result<()> {
2486        self.persist_identity_at(&Self::identity_path(attempt), bytes)
2487    }
2488
2489    fn persist_fallback_identity(
2490        &self,
2491        attempt: &RunnerAttempt,
2492        bytes: &[u8],
2493    ) -> std::io::Result<()> {
2494        self.persist_identity_at(&Self::fallback_identity_path(attempt), bytes)
2495    }
2496
2497    fn persist_identity_at(&self, path: &Path, bytes: &[u8]) -> std::io::Result<()> {
2498        #[cfg(test)]
2499        if self.faults_at(PostSpawnBoundary::IdentityWrite) {
2500            return Err(std::io::Error::other("injected identity write failure"));
2501        }
2502        write_durable_file(path, bytes)
2503    }
2504
2505    fn intent_path(attempt: &RunnerAttempt) -> PathBuf {
2506        attempt.runtime_path().join(TERMINATE_INTENT_FILE)
2507    }
2508
2509    fn read_identity(attempt: &RunnerAttempt) -> Result<Option<ProcessIdentity>, FailureReason> {
2510        match Self::read_identity_at(&Self::identity_path(attempt))? {
2511            Some(identity) => Ok(Some(identity)),
2512            None => Self::read_identity_at(&Self::fallback_identity_path(attempt)),
2513        }
2514    }
2515
2516    fn read_identity_at(path: &Path) -> Result<Option<ProcessIdentity>, FailureReason> {
2517        match fs::read(path) {
2518            Ok(bytes) => serde_json::from_slice(&bytes)
2519                .map(Some)
2520                .map_err(|_| FailureReason::Other("process identity journal is unreadable".into())),
2521            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
2522            Err(_) => Err(FailureReason::Other(
2523                "process identity journal could not be read".into(),
2524            )),
2525        }
2526    }
2527}
2528
2529impl ProcessSupervisor for NativeProcesses {
2530    fn prepare(
2531        &self,
2532        attempt: &RunnerAttempt,
2533        policy: &ScalePolicy,
2534    ) -> Result<PreparedEnvironment, FailureReason> {
2535        if !attempt.execution().is_native() || !policy.execution_policy().is_native() {
2536            return Err(FailureReason::Other(
2537                "execution provider unavailable".into(),
2538            ));
2539        }
2540        self.host_env()?;
2541        Ok(PreparedEnvironment {
2542            attempt: attempt.id,
2543            identity: None,
2544        })
2545    }
2546
2547    fn spawn(
2548        &self,
2549        attempt: &RunnerAttempt,
2550        config: &EncodedJitConfig,
2551    ) -> Result<u32, ProcessStartFailure> {
2552        let handoff = RestrictiveHandoff::create(
2553            attempt.runtime_path(),
2554            SecretString::from(config.expose().to_owned()),
2555        )
2556        .map_err(|_| ProcessStartFailure::before_spawn(FailureReason::ProcessStartFailed))?;
2557        #[cfg(windows)]
2558        let program = attempt
2559            .runtime_path()
2560            .join("bin")
2561            .join("Runner.Listener.exe");
2562        #[cfg(not(windows))]
2563        let program = attempt.runtime_path().join("bin").join("Runner.Listener");
2564        // Checked after the handoff exists on purpose: the error path below is
2565        // a real post-handoff launch failure, and unwinding must delete it.
2566        if !program.is_file() {
2567            return Err(ProcessStartFailure::before_spawn(
2568                FailureReason::ProcessStartFailed,
2569            ));
2570        }
2571        let host_env = self.host_env().map_err(ProcessStartFailure::before_spawn)?;
2572        #[cfg(test)]
2573        let spec = if self
2574            .use_long_lived_test_listener
2575            .load(std::sync::atomic::Ordering::SeqCst)
2576        {
2577            SpawnSpec::new(program)
2578                .args([
2579                    "--ignored",
2580                    "--exact",
2581                    "lifecycle::tests::long_lived_native_listener_helper",
2582                    "--nocapture",
2583                ])
2584                .env(
2585                    "RUNNER_MANAGER_TEST_LISTENER_READY",
2586                    attempt.runtime_path().join(TEST_LISTENER_READY),
2587                )
2588                .working_dir(attempt.runtime_path())
2589        } else {
2590            runner_listener_spec(program, attempt.runtime_path(), &host_env)
2591        };
2592        #[cfg(not(test))]
2593        let spec = runner_listener_spec(program, attempt.runtime_path(), &host_env);
2594        let child = spec
2595            .spawn_runner_with_handoff(&handoff)
2596            .map_err(|_| ProcessStartFailure::before_spawn(FailureReason::ProcessStartFailed))?;
2597        // The payload is gone before any state saying "starting" is persisted.
2598        #[cfg(test)]
2599        if self.faults_at(PostSpawnBoundary::HandoffDelete) {
2600            drop(handoff);
2601            return Err(self.abort_spawned_child(child, attempt, false));
2602        }
2603        if handoff.delete().is_err() {
2604            return Err(self.abort_spawned_child(child, attempt, false));
2605        }
2606        #[cfg(test)]
2607        if self.faults_at(PostSpawnBoundary::IdentitySerialize) {
2608            return Err(self.abort_spawned_child(child, attempt, false));
2609        }
2610        let identity = match serde_json::to_vec(child.identity()) {
2611            Ok(identity) => identity,
2612            Err(_) => {
2613                return Err(self.abort_spawned_child(child, attempt, false));
2614            }
2615        };
2616        if self.persist_identity(attempt, &identity).is_err() {
2617            return Err(self.abort_spawned_child(child, attempt, true));
2618        }
2619        let pid = child.pid();
2620        #[cfg(test)]
2621        if self.faults_at(PostSpawnBoundary::ChildMapInsert) {
2622            return Err(self.abort_spawned_child(child, attempt, true));
2623        }
2624        let mut children = self
2625            .children
2626            .lock()
2627            .unwrap_or_else(std::sync::PoisonError::into_inner);
2628        children.insert(attempt.id, child);
2629        Ok(pid)
2630    }
2631
2632    fn is_alive(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
2633        let mut children = self
2634            .children
2635            .lock()
2636            .unwrap_or_else(std::sync::PoisonError::into_inner);
2637        if let Some(child) = children.get_mut(&attempt.id) {
2638            return match child
2639                .try_exit_status()
2640                .map_err(|_| FailureReason::Other("runner process could not be observed".into()))?
2641            {
2642                None => Ok(true),
2643                Some(status) => {
2644                    if let Ok(mut exits) = self.successful_exits.lock() {
2645                        exits.insert(attempt.id, status.success());
2646                    }
2647                    Ok(false)
2648                }
2649            };
2650        }
2651        let Some(identity) = Self::read_identity(attempt)? else {
2652            if attempt.process_id().is_some() || Self::unresolved_process_path(attempt).is_file() {
2653                return Err(FailureReason::Other(
2654                    "runner process identity is missing; refusing recovery until the process is resolved"
2655                        .into(),
2656                ));
2657            }
2658            return Ok(false);
2659        };
2660        match identity.recheck() {
2661            Ok(Adoption::Live) => Ok(true),
2662            Ok(Adoption::Gone | Adoption::PidRecycled { .. }) => Ok(false),
2663            Err(_) => Ok(false),
2664        }
2665    }
2666
2667    fn recovered_pid(&self, attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason> {
2668        Ok(Self::read_identity(attempt)?.map(|identity| identity.pid()))
2669    }
2670
2671    fn completed_successfully(&self, attempt: &RunnerAttempt) -> bool {
2672        self.successful_exits
2673            .lock()
2674            .ok()
2675            .and_then(|exits| exits.get(&attempt.id).copied())
2676            .unwrap_or(false)
2677    }
2678
2679    fn record_terminate_intent(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason> {
2680        let path = Self::intent_path(attempt);
2681        write_durable_file(&path, b"registration-timeout\n")
2682            .map_err(|_| FailureReason::Other("terminate intent could not be journalled".into()))
2683    }
2684
2685    fn has_terminate_intent(&self, attempt: &RunnerAttempt) -> bool {
2686        Self::intent_path(attempt).is_file()
2687    }
2688
2689    fn terminate(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason> {
2690        let mut children = self
2691            .children
2692            .lock()
2693            .unwrap_or_else(std::sync::PoisonError::into_inner);
2694        if let Some(child) = children.get_mut(&attempt.id) {
2695            child
2696                .stop(Duration::from_secs(10))
2697                .map_err(|_| FailureReason::Other("runner process could not be stopped".into()))?;
2698            return Ok(());
2699        }
2700        let Some(identity) = Self::read_identity(attempt)? else {
2701            return Ok(());
2702        };
2703        match identity
2704            .terminate(Duration::from_secs(10))
2705            .map_err(|_| FailureReason::Other("runner process could not be stopped".into()))?
2706        {
2707            Termination::Terminated | Termination::AlreadyGone => Ok(()),
2708            Termination::RefusedPidRecycled { .. } => Err(FailureReason::Other(
2709                "runner PID was recycled; refusing to signal it".into(),
2710            )),
2711        }
2712    }
2713}
2714
2715pub struct LifecyclePorts {
2716    pub store: Arc<dyn Store>,
2717    pub github: Arc<dyn LifecycleGithub>,
2718    pub packages: Arc<dyn RuntimePackages>,
2719    pub processes: Arc<dyn ExecutionProvider>,
2720    pub clock: Arc<dyn Clock>,
2721    pub demand: Arc<dyn DemandPersistence>,
2722    pub delay: Arc<dyn RetryDelay>,
2723    pub events: Arc<dyn AttemptEventSink>,
2724    pub reconcile_events: Arc<dyn EventSink>,
2725}
2726
2727impl fmt::Debug for LifecyclePorts {
2728    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2729        f.debug_struct("LifecyclePorts")
2730            .field("store", &self.store)
2731            .field("github", &self.github)
2732            .field("packages", &self.packages)
2733            .field("processes", &self.processes)
2734            .finish_non_exhaustive()
2735    }
2736}
2737
2738#[derive(Debug, thiserror::Error)]
2739pub enum LifecycleError {
2740    #[error("attempt journal operation failed")]
2741    Journal,
2742    #[error("attempt {0} is not in the journal")]
2743    Missing(AttemptId),
2744    #[error("attempt lifecycle transition was refused")]
2745    Transition,
2746    #[error("startup recovery has not completed")]
2747    RecoveryIncomplete,
2748    /// A persistent slot could not be proven safe to scrub, so the attempt keeps
2749    /// its uncleaned state and, with it, its slot lease.
2750    ///
2751    /// Separate from [`Self::Failed`] because the two demand opposite handling
2752    /// from the same call sites. A failure aborts the pass; a quarantine must
2753    /// not, or one stuck slot would stop the host launching anything at all —
2754    /// which is precisely what `04-security-recovery.md` rules out when it says
2755    /// such an attempt "does not count as active host capacity" and "recovery
2756    /// retries the same cleanup".
2757    #[error("the persistent slot was not cleaned: {detail}")]
2758    SlotQuarantined {
2759        /// The closed-vocabulary event field; never operator or workflow text.
2760        class: &'static str,
2761        detail: String,
2762    },
2763    /// An ephemeral runtime is still held by a late process. Its journal state
2764    /// remains unchanged and ordinary supervision retries it, but startup
2765    /// recovery must not take unrelated repositories offline because of it.
2766    #[error("{}", FailureReason::WorkspaceCleanupDeferred)]
2767    WorkspaceCleanupDeferred,
2768    #[error("isolated environment cleanup is deferred")]
2769    EnvironmentCleanupDeferred,
2770    #[error("runner lifecycle failed: {0}")]
2771    Failed(FailureReason),
2772}
2773
2774impl LifecycleError {
2775    fn reason(&self) -> FailureReason {
2776        match self {
2777            Self::Failed(reason) => reason.clone(),
2778            Self::RecoveryIncomplete => FailureReason::Other("startup recovery incomplete".into()),
2779            Self::Journal => FailureReason::Other("attempt journal operation failed".into()),
2780            Self::Missing(_) => FailureReason::Other("attempt disappeared from the journal".into()),
2781            Self::Transition => FailureReason::Other("attempt transition was refused".into()),
2782            // Rendered through `Display` rather than a second copy of the same
2783            // sentence, so the two cannot drift apart.
2784            Self::SlotQuarantined { .. } => FailureReason::Other(self.to_string()),
2785            Self::WorkspaceCleanupDeferred => FailureReason::WorkspaceCleanupDeferred,
2786            Self::EnvironmentCleanupDeferred => FailureReason::Other(self.to_string()),
2787        }
2788    }
2789}
2790
2791/// Production implementation of e1's launcher port.
2792#[derive(Debug)]
2793pub struct LifecycleLauncher {
2794    host_id: HostId,
2795    app_paths: runner_manager_platform::paths::AppPaths,
2796    diagnostics_root: PathBuf,
2797    runner_group_id: u64,
2798    timeouts: RecoveryTimeouts,
2799    retry: RetryPolicy,
2800    cancel: CancelToken,
2801    ports: LifecyclePorts,
2802    recovery_complete: Mutex<bool>,
2803    versions: Mutex<BTreeMap<AttemptId, RunnerVersion>>,
2804    pending_replacements: Mutex<BTreeMap<AttemptId, ReplacementIntent>>,
2805    /// Backoff for cleanups [`Self::supervise`] tolerates and retries, so a
2806    /// workspace that cannot be removed is not retried and reported on every
2807    /// pass.
2808    cleanup_retries: Mutex<CleanBackoff>,
2809}
2810
2811#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2812enum ReconcileProgress {
2813    Reconciled,
2814    Deferred,
2815    Replacement {
2816        attempt: AttemptId,
2817        operation: &'static str,
2818    },
2819}
2820
2821impl LifecycleLauncher {
2822    #[must_use]
2823    pub fn new(
2824        host_id: HostId,
2825        app_paths: runner_manager_platform::paths::AppPaths,
2826        diagnostics_root: impl Into<PathBuf>,
2827        runner_group_id: u64,
2828        timeouts: RecoveryTimeouts,
2829        retry: RetryPolicy,
2830        ports: LifecyclePorts,
2831    ) -> Self {
2832        Self {
2833            host_id,
2834            app_paths,
2835            diagnostics_root: diagnostics_root.into(),
2836            runner_group_id,
2837            timeouts,
2838            retry,
2839            cancel: CancelToken::new(),
2840            ports,
2841            recovery_complete: Mutex::new(false),
2842            versions: Mutex::new(BTreeMap::new()),
2843            pending_replacements: Mutex::new(BTreeMap::new()),
2844            cleanup_retries: Mutex::new(CleanBackoff::default()),
2845        }
2846    }
2847
2848    /// Reconcile the entire journal before allowing a launch.  Unknown-policy
2849    /// attempts are left untouched rather than being acted on without an
2850    /// ownership proof.
2851    pub async fn recover_startup(
2852        &self,
2853        policies: &[ScalePolicy],
2854    ) -> Result<Vec<ReplacementIntent>, LifecycleError> {
2855        *self
2856            .recovery_complete
2857            .lock()
2858            .map_err(|_| LifecycleError::Journal)? = false;
2859        let by_id: BTreeMap<_, _> = policies.iter().map(|policy| (policy.id, policy)).collect();
2860        let attempts = self
2861            .ports
2862            .store
2863            .attempts()
2864            .map_err(|_| LifecycleError::Journal)?;
2865        let owned = self.ports.processes.enumerate_owned(self.host_id);
2866        for identity in &owned {
2867            if let EnvironmentIdentity::Isolated { attempt: id, .. } = identity
2868                && !attempts.iter().any(|attempt| {
2869                    attempt.state() != AttemptState::Cleaned
2870                        && identity_matches_intent(self.host_id, attempt, identity)
2871                })
2872            {
2873                // Enumeration is discovery, not authority to delete.
2874                self.ports
2875                    .events
2876                    .emit(AttemptEvent::QuarantinedOwnedResource { attempt: *id });
2877                tracing::warn!(host_id = %self.host_id, attempt_id = %id, reason = "owned_isolated_resource_without_matching_journal", "provider resource quarantined for operator review");
2878            }
2879        }
2880        let mut unresolved = false;
2881        for mut attempt in attempts {
2882            let Some(policy) = by_id.get(&attempt.policy_id) else {
2883                if attempt.counts_against_capacity() {
2884                    unresolved = true;
2885                }
2886                continue;
2887            };
2888            authorize(self.host_id, policy, &attempt).map_err(|_| LifecycleError::Journal)?;
2889            let allocation_resources = owned
2890                .iter()
2891                .filter(|identity| identity_same_allocation(self.host_id, &attempt, identity))
2892                .collect::<Vec<_>>();
2893            if allocation_resources.len() > 1 {
2894                self.ports
2895                    .events
2896                    .emit(AttemptEvent::QuarantinedOwnedResource {
2897                        attempt: attempt.id,
2898                    });
2899                tracing::warn!(attempt_id = %attempt.id, reason = "duplicate_isolated_resource_ownership", "provider resources quarantined for operator review");
2900            }
2901            if attempt.state() != AttemptState::Cleaned
2902                && matches!(
2903                    attempt.execution(),
2904                    AttemptExecution::Isolated {
2905                        environment_id: None,
2906                        ..
2907                    }
2908                )
2909                && allocation_resources.len() == 1
2910                && let Some(EnvironmentIdentity::Isolated { environment_id, .. }) =
2911                    allocation_resources.first().copied()
2912            {
2913                attempt
2914                    .prepared_environment(environment_id.clone())
2915                    .map_err(|_| LifecycleError::Transition)?;
2916                if attempt.state() == AttemptState::Preparing {
2917                    attempt
2918                        .mark_prepared(self.ports.clock.now())
2919                        .map_err(|_| LifecycleError::Transition)?;
2920                }
2921                self.record(&attempt)?;
2922            }
2923            match self.reconcile_one(policy, attempt).await? {
2924                ReconcileProgress::Deferred => {
2925                    if policy.execution_policy().is_native() {
2926                        unresolved = true;
2927                    }
2928                }
2929                ReconcileProgress::Replacement { attempt, operation } => {
2930                    self.pending_replacements
2931                        .lock()
2932                        .map_err(|_| LifecycleError::Journal)?
2933                        .insert(
2934                            attempt,
2935                            ReplacementIntent {
2936                                policy: policy.id,
2937                                previous_attempt: attempt,
2938                                operation,
2939                            },
2940                        );
2941                }
2942                ReconcileProgress::Reconciled => {}
2943            }
2944        }
2945        if unresolved {
2946            return Err(LifecycleError::RecoveryIncomplete);
2947        }
2948        *self
2949            .recovery_complete
2950            .lock()
2951            .map_err(|_| LifecycleError::Journal)? = true;
2952        Ok(self
2953            .pending_replacements
2954            .lock()
2955            .map_err(|_| LifecycleError::Journal)?
2956            .values()
2957            .copied()
2958            .collect())
2959    }
2960
2961    /// Supervise all attempts of one policy during an ordinary poll.
2962    pub async fn supervise(
2963        &self,
2964        policy: &ScalePolicy,
2965    ) -> Result<Vec<ReplacementIntent>, LifecycleError> {
2966        let mut replacements = Vec::new();
2967        self.pending_replacements
2968            .lock()
2969            .map_err(|_| LifecycleError::Journal)?
2970            .retain(|_, intent| {
2971                if intent.policy == policy.id {
2972                    replacements.push(*intent);
2973                    false
2974                } else {
2975                    true
2976                }
2977            });
2978        let attempts = self
2979            .ports
2980            .store
2981            .attempts_for_policy(policy.id)
2982            .map_err(|_| LifecycleError::Journal)?;
2983        for attempt in attempts {
2984            authorize(self.host_id, policy, &attempt).map_err(|_| LifecycleError::Journal)?;
2985            if let ReconcileProgress::Replacement { attempt, operation } =
2986                self.reconcile_one(policy, attempt).await?
2987            {
2988                replacements.push(ReplacementIntent {
2989                    policy: policy.id,
2990                    previous_attempt: attempt,
2991                    operation,
2992                });
2993            }
2994        }
2995        Ok(replacements)
2996    }
2997
2998    async fn reconcile_one(
2999        &self,
3000        policy: &ScalePolicy,
3001        mut attempt: RunnerAttempt,
3002    ) -> Result<ReconcileProgress, LifecycleError> {
3003        if !attempt.execution().is_native() {
3004            return self.reconcile_isolated_one(policy, attempt).await;
3005        }
3006        if attempt.state() == AttemptState::Cleaned {
3007            // A child of Runner.Worker can outlive the runner process briefly.
3008            // If it recreates `_work` after `clean_attempt` removed the tree but
3009            // before the journal's final write, the journal truthfully reaches
3010            // `cleaned` and the directory appears again afterwards. Cleaned
3011            // attempts used to return here forever, making that late residue
3012            // invisible. The journalled ephemeral mode is the ownership proof:
3013            // retry the whole-tree removal, while a persistent slot deliberately
3014            // remains on disk with its retained `_work`.
3015            if matches!(attempt.workspace(), AttemptWorkspace::Ephemeral)
3016                && attempt.runtime_path().exists()
3017            {
3018                // The attempt is already terminal, has released its package
3019                // lease, and consumes no capacity. A late child may still hold
3020                // one of its recreated files open on Windows, so failure here
3021                // is residue to retry, not a reason to abort startup recovery
3022                // and take every unrelated repository offline.
3023                // Retried with backoff; the event is the one report, which the
3024                // event sink logs as a warning.
3025                let now = self.ports.clock.now();
3026                if !self.cleanup_retries().is_due(attempt.id, now) {
3027                    return Ok(ReconcileProgress::Reconciled);
3028                }
3029                match self.scrub_workspace(&attempt) {
3030                    Ok(()) => self.cleanup_retries().succeeded(attempt.id),
3031                    Err(LifecycleError::WorkspaceCleanupDeferred) => {
3032                        self.cleanup_retries().failed(attempt.id, now);
3033                        self.ports
3034                            .reconcile_events
3035                            .emit(LifecycleEvent::AttemptCleanFailed {
3036                                policy: attempt.policy_id,
3037                                attempt: attempt.id,
3038                                reason: "late_ephemeral_workspace_could_not_be_removed",
3039                            });
3040                    }
3041                    Err(error) => return Err(error),
3042                }
3043            }
3044            return Ok(ReconcileProgress::Reconciled);
3045        }
3046        if attempt.is_terminal() {
3047            self.clean_or_quarantine(&mut attempt)?;
3048            return Ok(ReconcileProgress::Reconciled);
3049        }
3050        let process_alive = self
3051            .ports
3052            .processes
3053            .inspect(&attempt)
3054            .map_err(LifecycleError::Failed)?
3055            == EnvironmentState::Running;
3056        let github = self
3057            .ports
3058            .github
3059            .observe(&policy.target, attempt.id, &self.cancel)
3060            .await;
3061
3062        // If the agent died after GitHub accepted the registration but before
3063        // the non-secret runner-id sidecar landed, inventory closes the gap.
3064        // Persist it before making any state decision so a second crash moves
3065        // the boundary forward rather than repeating it.
3066        if let Some(runner_id) = github.runner_id
3067            && read_runner_id(attempt.runtime_path()).is_none()
3068        {
3069            write_runner_id(attempt.runtime_path(), runner_id)?;
3070            self.ports
3071                .events
3072                .emit(AttemptEvent::RemoteIdentityRecovered {
3073                    attempt: attempt.id,
3074                    runner_id,
3075                });
3076        }
3077
3078        // The child identity is synced before `spawn` returns.  If the agent
3079        // crashed before the following `starting` journal write, recover that
3080        // exact PID and take the legal `jit_received -> starting` edge before
3081        // applying GitHub's authoritative idle/busy observation below.
3082        if attempt.state() == AttemptState::JitReceived
3083            && process_alive
3084            && let Some(pid) = self
3085                .ports
3086                .processes
3087                .recovered_pid(&attempt)
3088                .map_err(LifecycleError::Failed)?
3089        {
3090            attempt
3091                .started(pid, self.ports.clock.now())
3092                .map_err(|_| LifecycleError::Transition)?;
3093            self.record(&attempt)?;
3094        }
3095
3096        // A one-shot child owned by this invocation exited successfully after
3097        // GitHub had already reported it busy, and its ephemeral registration
3098        // is now gone.  This concludes the *runner attempt*, never the workflow
3099        // outcome; GitHub remains authoritative for that outcome.
3100        if attempt.state() == AttemptState::Busy
3101            && !process_alive
3102            && github.status == GithubRunnerObservation::NotRegistered
3103            && self.ports.processes.completed_successfully(&attempt)
3104        {
3105            self.conclude(&mut attempt, AttemptOutcome::CompletedJob)?;
3106            self.clean_or_quarantine(&mut attempt)?;
3107            return Ok(ReconcileProgress::Reconciled);
3108        }
3109
3110        // This durable mark is more authoritative than a later observation
3111        // which cannot distinguish an agent kill from a crash.
3112        if self.ports.processes.has_terminate_intent(&attempt) && !process_alive {
3113            self.deregister_runner(policy, &attempt).await;
3114            self.conclude(
3115                &mut attempt,
3116                AttemptOutcome::failed(FailureReason::TerminatedAfterRegistrationTimeout),
3117            )?;
3118            self.clean_or_quarantine(&mut attempt)?;
3119            return Ok(ReconcileProgress::Replacement {
3120                attempt: attempt.id,
3121                operation: "registration_timeout_replacement",
3122            });
3123        }
3124
3125        // A remote registration with no surviving process has lost its
3126        // one-shot JIT secret.  Walking it to `starting` would require inventing
3127        // a PID; retrying the same registration would require inventing the
3128        // secret.  Record the configuration as expired and let the bounded
3129        // replacement path request a fresh one only if demand remains.
3130        if matches!(
3131            attempt.state(),
3132            AttemptState::Allocated | AttemptState::JitReceived
3133        ) && !process_alive
3134            && matches!(github.status, GithubRunnerObservation::Registered { .. })
3135        {
3136            if attempt.state() == AttemptState::Allocated {
3137                attempt
3138                    .jit_received(self.ports.clock.now())
3139                    .map_err(|_| LifecycleError::Transition)?;
3140                self.record(&attempt)?;
3141            }
3142            self.deregister_runner(policy, &attempt).await;
3143            self.conclude(
3144                &mut attempt,
3145                AttemptOutcome::failed(FailureReason::JitExpired),
3146            )?;
3147            self.clean_or_quarantine(&mut attempt)?;
3148            return Ok(ReconcileProgress::Replacement {
3149                attempt: attempt.id,
3150                operation: "jit_expired_replacement",
3151            });
3152        }
3153
3154        match recovery_decision(
3155            &attempt,
3156            RecoveryObservation {
3157                process_alive,
3158                github: github.status,
3159            },
3160            self.timeouts,
3161            self.ports.clock.as_ref(),
3162        ) {
3163            RecoveryDecision::Nothing | RecoveryDecision::Wait => Ok(ReconcileProgress::Reconciled),
3164            RecoveryDecision::Defer => Ok(ReconcileProgress::Deferred),
3165            RecoveryDecision::Adopt => {
3166                self.ports.events.emit(AttemptEvent::Adopted {
3167                    attempt: attempt.id,
3168                });
3169                Ok(ReconcileProgress::Reconciled)
3170            }
3171            RecoveryDecision::Clean => {
3172                self.clean_or_quarantine(&mut attempt)?;
3173                Ok(ReconcileProgress::Reconciled)
3174            }
3175            RecoveryDecision::Observe(state) => {
3176                let runner_id = attempt
3177                    .github_runner_id()
3178                    .or(github.runner_id)
3179                    .or_else(|| read_runner_id(attempt.runtime_path()))
3180                    .ok_or(LifecycleError::Transition)?;
3181                match state {
3182                    AttemptState::JitReceived => attempt
3183                        .jit_received(self.ports.clock.now())
3184                        .map_err(|_| LifecycleError::Transition)?,
3185                    AttemptState::Starting => {
3186                        let pid = attempt.process_id().ok_or(LifecycleError::Transition)?;
3187                        attempt
3188                            .started(pid, self.ports.clock.now())
3189                            .map_err(|_| LifecycleError::Transition)?;
3190                    }
3191                    AttemptState::Idle => attempt
3192                        .registered_idle(runner_id, self.ports.clock.now())
3193                        .map_err(|_| LifecycleError::Transition)?,
3194                    AttemptState::Busy => attempt
3195                        .assigned_job(runner_id, self.ports.clock.now())
3196                        .map_err(|_| LifecycleError::Transition)?,
3197                    _ => return Err(LifecycleError::Transition),
3198                }
3199                self.record(&attempt)?;
3200                Ok(ReconcileProgress::Reconciled)
3201            }
3202            RecoveryDecision::Conclude(outcome) => {
3203                let replacement = replacement_operation(&outcome);
3204                // Only when GitHub still holds one. Every other conclusion here
3205                // was reached *because* the observation was `NotRegistered`, and
3206                // spending a DELETE to be told so again would put a request per
3207                // concluded attempt on a budget `rest.rs` prices to the request.
3208                if matches!(github.status, GithubRunnerObservation::Registered { .. }) {
3209                    self.deregister_runner(policy, &attempt).await;
3210                }
3211                self.conclude(&mut attempt, outcome)?;
3212                self.clean_or_quarantine(&mut attempt)?;
3213                Ok(
3214                    replacement.map_or(ReconcileProgress::Reconciled, |operation| {
3215                        ReconcileProgress::Replacement {
3216                            attempt: attempt.id,
3217                            operation,
3218                        }
3219                    }),
3220                )
3221            }
3222            RecoveryDecision::Terminate(payload) => {
3223                // The mark is synced first, and what proves the process died is
3224                // the `is_alive` re-read below -- not the outcome recorded after
3225                // it. Which outcome that is depends on why the termination was
3226                // ordered, and only the payload knows: a `starting` runner that
3227                // never registered is a failure this agent then stopped, while
3228                // an `idle` one past its timeout is flow 2.7's surplus exit and
3229                // no failure at all. Hardcoding the first reason here labelled
3230                // the second as a registration timeout and asked the allocator
3231                // for a replacement to boot.
3232                let idle_exit = payload.is_idle_exit();
3233                self.ports
3234                    .processes
3235                    .record_terminate_intent(&attempt)
3236                    .map_err(LifecycleError::Failed)?;
3237                self.ports.events.emit(AttemptEvent::TerminateIntent {
3238                    attempt: attempt.id,
3239                });
3240                if self
3241                    .ports
3242                    .processes
3243                    .stop(&attempt)
3244                    .map_err(LifecycleError::Failed)?
3245                    == ProviderStop::StillRunning
3246                {
3247                    return Ok(ReconcileProgress::Deferred);
3248                }
3249                if self
3250                    .ports
3251                    .processes
3252                    .inspect(&attempt)
3253                    .map_err(LifecycleError::Failed)?
3254                    == EnvironmentState::Running
3255                {
3256                    return Ok(ReconcileProgress::Deferred);
3257                }
3258                self.ports.events.emit(AttemptEvent::Terminated {
3259                    attempt: attempt.id,
3260                });
3261                // The registration-timeout path keeps deriving its own reason
3262                // rather than applying the payload: on the pass that reads the
3263                // journalled mark back the process is dead, and
3264                // `TerminatedAfterRegistrationTimeout` is the reason that stays
3265                // true of a dead process. See `RecoveryDecision::Terminate`.
3266                let outcome = if idle_exit {
3267                    AttemptOutcome::ExitedIdleWithoutWork
3268                } else {
3269                    AttemptOutcome::failed(FailureReason::TerminatedAfterRegistrationTimeout)
3270                };
3271                self.deregister_runner(policy, &attempt).await;
3272                self.conclude(&mut attempt, outcome)?;
3273                self.clean_or_quarantine(&mut attempt)?;
3274                // A surplus runner is not replaced. It was stopped precisely
3275                // because the work it was started for went elsewhere; asking the
3276                // allocator for another one rebuilds it every idle timeout.
3277                if idle_exit {
3278                    Ok(ReconcileProgress::Reconciled)
3279                } else {
3280                    Ok(ReconcileProgress::Replacement {
3281                        attempt: attempt.id,
3282                        operation: "registration_timeout_replacement",
3283                    })
3284                }
3285            }
3286        }
3287    }
3288
3289    async fn reconcile_isolated_one(
3290        &self,
3291        policy: &ScalePolicy,
3292        mut attempt: RunnerAttempt,
3293    ) -> Result<ReconcileProgress, LifecycleError> {
3294        if attempt.state() == AttemptState::Cleaned {
3295            return Ok(ReconcileProgress::Reconciled);
3296        }
3297        if attempt.is_terminal() {
3298            self.clean_or_quarantine(&mut attempt)?;
3299            return Ok(ReconcileProgress::Reconciled);
3300        }
3301        if matches!(
3302            attempt.state(),
3303            AttemptState::Allocated | AttemptState::Preparing | AttemptState::Prepared
3304        ) {
3305            // No JIT was requested yet. Restart can safely retire this attempt;
3306            // a matching owned resource is stopped by the durable cleanup path.
3307            self.conclude(
3308                &mut attempt,
3309                AttemptOutcome::failed(FailureReason::Other(
3310                    "isolated preparation interrupted".into(),
3311                )),
3312            )?;
3313            self.clean_or_quarantine(&mut attempt)?;
3314            return Ok(ReconcileProgress::Reconciled);
3315        }
3316        let Ok(provider_state) = self.ports.processes.recover(&attempt) else {
3317            return Ok(ReconcileProgress::Deferred);
3318        };
3319        if provider_state != EnvironmentState::Missing {
3320            let Ok(owned) = self.ports.processes.owns(&attempt) else {
3321                return Ok(ReconcileProgress::Deferred);
3322            };
3323            if !owned {
3324                self.ports
3325                    .events
3326                    .emit(AttemptEvent::QuarantinedOwnedResource {
3327                        attempt: attempt.id,
3328                    });
3329                tracing::warn!(attempt_id = %attempt.id, reason = "isolated_resource_ownership_mismatch", "provider resource quarantined for operator review");
3330                return Ok(ReconcileProgress::Deferred);
3331            }
3332        }
3333        let live = matches!(
3334            provider_state,
3335            EnvironmentState::Starting | EnvironmentState::Running
3336        );
3337        if attempt.state() == AttemptState::JitReceived && live {
3338            attempt
3339                .started_isolated(self.ports.clock.now())
3340                .map_err(|_| LifecycleError::Transition)?;
3341            self.record(&attempt)?;
3342        }
3343        let github = self
3344            .ports
3345            .github
3346            .observe(&policy.target, attempt.id, &self.cancel)
3347            .await;
3348        if let Some(runner_id) = github.runner_id
3349            && read_runner_id(attempt.runtime_path()).is_none()
3350        {
3351            write_runner_id(attempt.runtime_path(), runner_id)?;
3352        }
3353        // A successfully reaped one-shot listener is stronger evidence than a
3354        // missing registration. Recovered guests have no attached child and
3355        // still follow the conservative orphan path below.
3356        if attempt.state() == AttemptState::Busy
3357            && !live
3358            && github.status == GithubRunnerObservation::NotRegistered
3359            && self.ports.processes.completed_successfully(&attempt)
3360        {
3361            self.conclude(&mut attempt, AttemptOutcome::CompletedJob)?;
3362            self.clean_or_quarantine(&mut attempt)?;
3363            return Ok(ReconcileProgress::Reconciled);
3364        }
3365        match recovery_decision(
3366            &attempt,
3367            RecoveryObservation {
3368                process_alive: live,
3369                github: github.status,
3370            },
3371            self.timeouts,
3372            self.ports.clock.as_ref(),
3373        ) {
3374            RecoveryDecision::Nothing | RecoveryDecision::Wait => Ok(ReconcileProgress::Reconciled),
3375            RecoveryDecision::Defer => Ok(ReconcileProgress::Deferred),
3376            RecoveryDecision::Adopt => {
3377                self.ports.events.emit(AttemptEvent::Adopted {
3378                    attempt: attempt.id,
3379                });
3380                Ok(ReconcileProgress::Reconciled)
3381            }
3382            RecoveryDecision::Clean => {
3383                self.clean_or_quarantine(&mut attempt)?;
3384                Ok(ReconcileProgress::Reconciled)
3385            }
3386            RecoveryDecision::Observe(state) => {
3387                let runner_id = github
3388                    .runner_id
3389                    .or_else(|| read_runner_id(attempt.runtime_path()))
3390                    .ok_or(LifecycleError::Transition)?;
3391                match state {
3392                    AttemptState::Idle => attempt
3393                        .registered_idle(runner_id, self.ports.clock.now())
3394                        .map_err(|_| LifecycleError::Transition)?,
3395                    AttemptState::Busy => attempt
3396                        .assigned_job(runner_id, self.ports.clock.now())
3397                        .map_err(|_| LifecycleError::Transition)?,
3398                    _ => return Err(LifecycleError::Transition),
3399                }
3400                self.record(&attempt)?;
3401                Ok(ReconcileProgress::Reconciled)
3402            }
3403            RecoveryDecision::Conclude(outcome) | RecoveryDecision::Terminate(outcome) => {
3404                let replacement = replacement_operation(&outcome);
3405                if matches!(github.status, GithubRunnerObservation::Registered { .. }) {
3406                    self.deregister_runner(policy, &attempt).await;
3407                }
3408                self.conclude(&mut attempt, outcome)?;
3409                self.clean_or_quarantine(&mut attempt)?;
3410                Ok(
3411                    replacement.map_or(ReconcileProgress::Reconciled, |operation| {
3412                        ReconcileProgress::Replacement {
3413                            attempt: attempt.id,
3414                            operation,
3415                        }
3416                    }),
3417                )
3418            }
3419        }
3420    }
3421
3422    fn record(&self, attempt: &RunnerAttempt) -> Result<(), LifecycleError> {
3423        self.ports
3424            .store
3425            .record_attempt(attempt)
3426            .map_err(|_| LifecycleError::Journal)?;
3427        self.ports.events.emit(AttemptEvent::State {
3428            attempt: attempt.id,
3429            state: attempt.state(),
3430        });
3431        Ok(())
3432    }
3433
3434    /// Remove the GitHub registration an attempt is about to leave behind.
3435    ///
3436    /// # Why this is not fallible, and does not block the conclusion
3437    ///
3438    /// GitHub retires an ephemeral runner itself once that runner *completes a
3439    /// job*, and for the ordinary path that is the whole story. The paths that
3440    /// reach here are the ones where it does not: a runner stopped before it
3441    /// was ever assigned work, a registration whose process died still holding
3442    /// it, a JIT configuration that expired. Nothing else deletes those, and
3443    /// before this existed nothing did — they accumulated in the target's
3444    /// runner settings, one row per attempt, for the life of the repository.
3445    ///
3446    /// It returns `()` rather than a `Result` because the alternative is worse
3447    /// in both directions. The attempt is over: its process is gone and its
3448    /// slot has to come back, so a failed delete may not abort the conclusion
3449    /// or the host leaks capacity every time GitHub is unreachable. And a
3450    /// registration that outlives this call is not lost — it is exactly the
3451    /// `Registered` + dead-process observation that
3452    /// [`AttemptOutcome::Orphaned`] already names, which a later pass can still
3453    /// see. So a failure is logged and stepped over, deliberately.
3454    async fn deregister_runner(&self, policy: &ScalePolicy, attempt: &RunnerAttempt) {
3455        let Some(runner_id) = attempt
3456            .github_runner_id()
3457            .or_else(|| read_runner_id(attempt.runtime_path()))
3458        else {
3459            return;
3460        };
3461        if self
3462            .ports
3463            .github
3464            .deregister(&policy.target, runner_id, &self.cancel)
3465            .await
3466        {
3467            self.ports.events.emit(AttemptEvent::Deregistered {
3468                attempt: attempt.id,
3469                runner_id,
3470            });
3471        } else {
3472            tracing::warn!(
3473                attempt = %attempt.id,
3474                runner_id,
3475                "the runner registration could not be removed from GitHub; it will show in the \
3476                 target's runner settings until GitHub retires it or a later pass removes it"
3477            );
3478        }
3479    }
3480
3481    fn conclude(
3482        &self,
3483        attempt: &mut RunnerAttempt,
3484        outcome: AttemptOutcome,
3485    ) -> Result<(), LifecycleError> {
3486        attempt
3487            .conclude(outcome.clone(), self.ports.clock.now())
3488            .map_err(|_| LifecycleError::Transition)?;
3489        self.record(attempt)?;
3490        self.ports.events.emit(AttemptEvent::Concluded {
3491            attempt: attempt.id,
3492            outcome: OutcomeKind::of(&outcome),
3493        });
3494        Ok(())
3495    }
3496
3497    /// Clean a concluded attempt, tolerating a quarantined persistent slot.
3498    ///
3499    /// The quarantine is reported and stepped over rather than raised, because
3500    /// raising it aborts the whole pass: on the startup path that leaves
3501    /// `recovery_complete` false and stops the host launching anything, which is
3502    /// the opposite of `04-security-recovery.md`'s "it does not count as active
3503    /// host capacity" and "recovery retries the same cleanup". The attempt keeps
3504    /// its state, so it keeps its slot lease and its directory, and the next
3505    /// pass — [`crate::reconcile::Reconciler`]'s terminal sweep on every poll,
3506    /// or the next startup — attempts exactly the same cleanup again.
3507    ///
3508    /// A persistent-slot quarantine and an ephemeral directory still held by a
3509    /// late process are tolerated. A journal failure or a package lease that
3510    /// cannot be released still propagates: those are not one workspace's
3511    /// problem.
3512    ///
3513    /// A tolerated failure is retried with per-attempt backoff: until it is
3514    /// due again the attempt is skipped silently rather than re-tried and
3515    /// re-reported on every pass.
3516    fn clean_or_quarantine(&self, attempt: &mut RunnerAttempt) -> Result<(), LifecycleError> {
3517        let now = self.ports.clock.now();
3518        if !self.cleanup_retries().is_due(attempt.id, now) {
3519            return Ok(());
3520        }
3521        let reason = match self.clean_attempt(attempt) {
3522            Err(LifecycleError::SlotQuarantined { class, .. }) => class,
3523            Err(LifecycleError::WorkspaceCleanupDeferred) => {
3524                failure_reason_kind(&FailureReason::WorkspaceCleanupDeferred)
3525            }
3526            Err(LifecycleError::EnvironmentCleanupDeferred) => {
3527                "isolated_environment_cleanup_deferred"
3528            }
3529            other => {
3530                if other.is_ok() {
3531                    self.cleanup_retries().succeeded(attempt.id);
3532                }
3533                return other;
3534            }
3535        };
3536        self.cleanup_retries().failed(attempt.id, now);
3537        self.ports
3538            .reconcile_events
3539            .emit(LifecycleEvent::AttemptCleanFailed {
3540                policy: attempt.policy_id,
3541                attempt: attempt.id,
3542                reason,
3543            });
3544        Ok(())
3545    }
3546
3547    fn cleanup_retries(&self) -> std::sync::MutexGuard<'_, CleanBackoff> {
3548        self.cleanup_retries
3549            .lock()
3550            .unwrap_or_else(std::sync::PoisonError::into_inner)
3551    }
3552
3553    fn clean_attempt(&self, attempt: &mut RunnerAttempt) -> Result<(), LifecycleError> {
3554        if !attempt.execution().is_native() {
3555            return self.clean_isolated_attempt(attempt);
3556        }
3557        match self
3558            .ports
3559            .processes
3560            .destroy(attempt)
3561            .map_err(LifecycleError::Failed)?
3562        {
3563            ProviderDestroy::Destroyed => {}
3564            ProviderDestroy::Deferred(reason) => {
3565                return Err(LifecycleError::Failed(FailureReason::Other(reason.into())));
3566            }
3567        }
3568        let outcome = attempt
3569            .outcome()
3570            .cloned()
3571            .ok_or(LifecycleError::Transition)?;
3572        self.preserve_diagnostics(attempt, &outcome)?;
3573        self.scrub_workspace(attempt)?;
3574        self.ports
3575            .packages
3576            .release(attempt.id)
3577            .map_err(LifecycleError::Failed)?;
3578        attempt
3579            .clean(self.ports.clock.now())
3580            .map_err(|_| LifecycleError::Transition)?;
3581        self.record(attempt)?;
3582        let kind = OutcomeKind::of(&outcome);
3583        self.ports.events.emit(AttemptEvent::Cleaned {
3584            attempt: attempt.id,
3585            outcome: kind,
3586        });
3587        self.ports
3588            .reconcile_events
3589            .emit(LifecycleEvent::AttemptCleaned {
3590                policy: attempt.policy_id,
3591                attempt: attempt.id,
3592                outcome: kind,
3593            });
3594        Ok(())
3595    }
3596
3597    fn clean_isolated_attempt(&self, attempt: &mut RunnerAttempt) -> Result<(), LifecycleError> {
3598        if attempt.state() != AttemptState::Destroying {
3599            attempt
3600                .begin_destroy(self.ports.clock.now())
3601                .map_err(|_| LifecycleError::Transition)?;
3602            self.record(attempt)?;
3603        }
3604        let provider = self.ports.processes.as_ref();
3605        let allocation_resources = provider.enumerate_owned(self.host_id);
3606        let matching_allocation = allocation_resources
3607            .iter()
3608            .filter(|identity| identity_same_allocation(self.host_id, attempt, identity))
3609            .collect::<Vec<_>>();
3610        if matching_allocation.len() > 1
3611            || matching_allocation
3612                .iter()
3613                .any(|identity| !identity_matches_intent(self.host_id, attempt, identity))
3614        {
3615            self.ports
3616                .events
3617                .emit(AttemptEvent::QuarantinedOwnedResource {
3618                    attempt: attempt.id,
3619                });
3620            tracing::warn!(attempt_id = %attempt.id, reason = "duplicate_or_mismatched_isolated_ownership", "provider resources quarantined for operator review");
3621            return Err(self.defer_environment_cleanup(attempt)?);
3622        }
3623        let has_identity = matches!(
3624            attempt.execution(),
3625            AttemptExecution::Isolated {
3626                environment_id: Some(_),
3627                ..
3628            }
3629        );
3630        if !has_identity
3631            && allocation_resources
3632                .iter()
3633                .any(|identity| identity_matches_intent(self.host_id, attempt, identity))
3634        {
3635            return Err(self.defer_environment_cleanup(attempt)?);
3636        }
3637        let current = if has_identity {
3638            provider.inspect(attempt)
3639        } else {
3640            // A prepare effect may have succeeded before its identity could be
3641            // journalled. Empty enumeration alone is not proof of absence.
3642            provider.recover(attempt)
3643        };
3644        let current = match current {
3645            Ok(state) => state,
3646            Err(_) => return Err(self.defer_environment_cleanup(attempt)?),
3647        };
3648        if !has_identity && current != EnvironmentState::Missing {
3649            return Err(self.defer_environment_cleanup(attempt)?);
3650        }
3651        if current != EnvironmentState::Missing {
3652            if !matches!(provider.owns(attempt), Ok(true)) {
3653                self.ports
3654                    .events
3655                    .emit(AttemptEvent::QuarantinedOwnedResource {
3656                        attempt: attempt.id,
3657                    });
3658                tracing::warn!(attempt_id = %attempt.id, reason = "isolated_resource_ownership_mismatch", "provider resource quarantined for operator review");
3659                return Err(self.defer_environment_cleanup(attempt)?);
3660            }
3661            if matches!(
3662                current,
3663                EnvironmentState::Starting | EnvironmentState::Running
3664            ) && !matches!(provider.stop(attempt), Ok(ProviderStop::Stopped))
3665            {
3666                return Err(self.defer_environment_cleanup(attempt)?);
3667            }
3668            if !matches!(provider.destroy(attempt), Ok(ProviderDestroy::Destroyed)) {
3669                return Err(self.defer_environment_cleanup(attempt)?);
3670            }
3671            if !matches!(provider.inspect(attempt), Ok(EnvironmentState::Missing)) {
3672                return Err(self.defer_environment_cleanup(attempt)?);
3673            }
3674        }
3675        if provider
3676            .enumerate_owned(self.host_id)
3677            .iter()
3678            .any(|identity| identity_same_allocation(self.host_id, attempt, identity))
3679        {
3680            return Err(self.defer_environment_cleanup(attempt)?);
3681        }
3682        let outcome = attempt
3683            .outcome()
3684            .cloned()
3685            .ok_or(LifecycleError::Transition)?;
3686        // Provider diagnostics are a closed vocabulary and bounded before they
3687        // can enter tracing, even if an adapter returns an unbounded vector.
3688        for diagnostic in provider.diagnostics(attempt).into_iter().take(32) {
3689            tracing::debug!(attempt_id = %attempt.id, provider_diagnostic = ?diagnostic, "provider lifecycle diagnostic");
3690        }
3691        self.preserve_diagnostics(attempt, &outcome)?;
3692        self.scrub_workspace(attempt)?;
3693        self.ports
3694            .packages
3695            .release(attempt.id)
3696            .map_err(LifecycleError::Failed)?;
3697        attempt
3698            .clean_isolated(self.ports.clock.now())
3699            .map_err(|_| LifecycleError::Transition)?;
3700        self.record(attempt)?;
3701        self.ports.events.emit(AttemptEvent::Cleaned {
3702            attempt: attempt.id,
3703            outcome: OutcomeKind::of(&outcome),
3704        });
3705        self.ports
3706            .reconcile_events
3707            .emit(LifecycleEvent::AttemptCleaned {
3708                policy: attempt.policy_id,
3709                attempt: attempt.id,
3710                outcome: OutcomeKind::of(&outcome),
3711            });
3712        Ok(())
3713    }
3714
3715    fn defer_environment_cleanup(
3716        &self,
3717        attempt: &mut RunnerAttempt,
3718    ) -> Result<LifecycleError, LifecycleError> {
3719        attempt
3720            .defer_cleanup(self.ports.clock.now())
3721            .map_err(|_| LifecycleError::Transition)?;
3722        self.record(attempt)?;
3723        Ok(LifecycleError::EnvironmentCleanupDeferred)
3724    }
3725
3726    /// Undo an attempt's placement by the algorithm its journalled workspace
3727    /// kind makes legal (`02-target-architecture.md`, "Cleanup and recovery").
3728    ///
3729    /// The dispatch is on the *journal*, never on what the directory looks like
3730    /// now. A slot whose `_work` was replaced by a junction is still scrubbed as
3731    /// a slot rather than removed whole, and a disposable directory that happens
3732    /// to contain a `_work` still goes whole rather than being spared: the
3733    /// workspace kind is immutable precisely so that the shape of a directory a
3734    /// workflow can write to cannot choose the algorithm applied to it.
3735    fn scrub_workspace(&self, attempt: &RunnerAttempt) -> Result<(), LifecycleError> {
3736        #[cfg(test)]
3737        {
3738            // The two contamination mutants `f1` drives the security gates with.
3739            // They sit in front of the dispatch rather than inside one arm so
3740            // that a skipped cleanup is equally observable in both modes.
3741            if matches!(
3742                std::env::var("RUNNER_MANAGER_TEST_MUTANT").as_deref(),
3743                Ok("skip_workspace_cleanup" | "reuse_job_workspace")
3744            ) {
3745                return Ok(());
3746            }
3747        }
3748        match attempt.workspace() {
3749            AttemptWorkspace::Ephemeral => match remove_runtime_tree(attempt.runtime_path()) {
3750                Ok(()) => Ok(()),
3751                Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
3752                Err(_) => Err(LifecycleError::WorkspaceCleanupDeferred),
3753            },
3754            AttemptWorkspace::PersistentSlot { slot } => self.scrub_persistent_slot(attempt, slot),
3755        }
3756    }
3757
3758    /// Retain exactly `_work` and prove everything else is gone.
3759    ///
3760    /// The seven ordered checks of `04-security-recovery.md`, "Safe path
3761    /// handling": the journalled root and slot, a surviving policy's agreement,
3762    /// lexical and canonical containment, a literal enumeration, the one-entry
3763    /// allowlist, and a verification pass before the caller releases the package
3764    /// lease and marks the attempt cleaned. Any of them may refuse, and a
3765    /// refusal removes nothing after it.
3766    ///
3767    /// A slot directory that is already gone is not a refusal. There is nothing
3768    /// to scrub and nothing to prove absent, which is the same tolerance the
3769    /// disposable arm has always had for a runtime that vanished under it.
3770    fn scrub_persistent_slot(
3771        &self,
3772        attempt: &RunnerAttempt,
3773        slot: NonZeroU16,
3774    ) -> Result<(), LifecycleError> {
3775        // The policy is read from the journal, never inferred from the
3776        // directory tree, and its absence is legal: `Reconciler` cleans every
3777        // concluded attempt whether or not its policy still exists, and this
3778        // one's root is already journalled on the attempt itself.
3779        let configured = self
3780            .ports
3781            .store
3782            .policy(attempt.policy_id)
3783            .map_err(|_| LifecycleError::Journal)?
3784            .and_then(|policy| match policy.workspace_policy() {
3785                WorkspacePolicy::Persistent { root } => Some(root.clone()),
3786                WorkspacePolicy::Ephemeral => None,
3787            });
3788        let runtime = attempt.runtime_path();
3789        self.quarantine_on_refusal(
3790            attempt,
3791            verify_journalled_slot(runtime, slot, configured.as_ref())
3792                .and_then(|()| slot_is_present(runtime))
3793                .and_then(|present| {
3794                    if present {
3795                        scrub_slot_entries(runtime).and_then(|()| verify_slot_scrubbed(runtime))
3796                    } else {
3797                        Ok(())
3798                    }
3799                }),
3800        )
3801    }
3802
3803    /// Turn a slot refusal into the error that keeps the attempt uncleaned.
3804    ///
3805    /// The warning is emitted here, at the one place a quarantine is minted, so
3806    /// that both routes out of cleanup carry it: the reconciler's terminal sweep,
3807    /// which receives the error through the launcher port, and
3808    /// [`Self::clean_or_quarantine`], which swallows it to keep the pass alive.
3809    /// Everything logged is either a path this product configured or a constant
3810    /// from this module — see [`SlotQuarantine`] for why that matters.
3811    fn quarantine_on_refusal(
3812        &self,
3813        attempt: &RunnerAttempt,
3814        outcome: Result<(), SlotQuarantine>,
3815    ) -> Result<(), LifecycleError> {
3816        let Err(quarantine) = outcome else {
3817            return Ok(());
3818        };
3819        let detail = quarantine.to_string();
3820        tracing::warn!(
3821            attempt = %attempt.id,
3822            policy = %attempt.policy_id,
3823            slot = attempt.workspace().slot_number(),
3824            refusal = quarantine.refusal.class(),
3825            "{detail}"
3826        );
3827        Err(LifecycleError::SlotQuarantined {
3828            class: quarantine.refusal.class(),
3829            detail,
3830        })
3831    }
3832
3833    fn preserve_diagnostics(
3834        &self,
3835        attempt: &RunnerAttempt,
3836        outcome: &AttemptOutcome,
3837    ) -> Result<(), LifecycleError> {
3838        fs::create_dir_all(&self.diagnostics_root).map_err(|_| {
3839            LifecycleError::Failed(FailureReason::Other(
3840                "diagnostics directory could not be created".into(),
3841            ))
3842        })?;
3843        // Intentionally constructed from typed local facts, not runner output.
3844        // Raw child output can contain workflow secrets and is never copied.
3845        let reason_kind = match outcome {
3846            AttemptOutcome::Failed { reason } => failure_reason_kind(reason),
3847            _ => "none",
3848        };
3849        let diagnostic = format!(
3850            "attempt_id={}\npolicy_id={}\noutcome={}\nreason={}\n",
3851            attempt.id,
3852            attempt.policy_id,
3853            OutcomeKind::of(outcome).as_str(),
3854            reason_kind,
3855        );
3856        fs::write(
3857            self.diagnostics_root.join(format!("{}.log", attempt.id)),
3858            diagnostic,
3859        )
3860        .map_err(|_| {
3861            LifecycleError::Failed(FailureReason::Other(
3862                "redacted diagnostics could not be preserved".into(),
3863            ))
3864        })
3865    }
3866
3867    async fn materialize_with_retry(
3868        &self,
3869        policy: &ScalePolicy,
3870        attempt: &RunnerAttempt,
3871    ) -> Result<RunnerVersion, FailureReason> {
3872        let mut issued = 0_u32;
3873        loop {
3874            issued = issued.saturating_add(1);
3875            match self.ports.packages.materialize(attempt).await {
3876                Ok(version) => return Ok(version),
3877                Err(reason)
3878                    if package_failure_is_terminal(&reason)
3879                        || issued >= self.retry.max_attempts.max(1) =>
3880                {
3881                    return Err(reason);
3882                }
3883                Err(reason) => {
3884                    if !self.ports.demand.persists(policy.id).await {
3885                        return Err(reason);
3886                    }
3887                    let delay = self.retry.delay(issued);
3888                    self.ports.events.emit(AttemptEvent::Retry {
3889                        attempt: attempt.id,
3890                        operation: "package_materialization",
3891                        delay,
3892                    });
3893                    self.ports.delay.wait(delay).await;
3894                    if !self.ports.demand.persists(policy.id).await {
3895                        return Err(reason);
3896                    }
3897                }
3898            }
3899        }
3900    }
3901
3902    async fn register_with_retry(
3903        &self,
3904        policy: &ScalePolicy,
3905        attempt: AttemptId,
3906        request: &JitRunnerRequest,
3907    ) -> Result<JitRegistration, LifecycleError> {
3908        let mut issued = 0_u32;
3909        loop {
3910            issued = issued.saturating_add(1);
3911            match self
3912                .ports
3913                .github
3914                .register(&policy.target, request, &self.cancel)
3915                .await
3916            {
3917                Ok(registration) => return Ok(registration),
3918                Err(error) if error.terminal => {
3919                    return Err(LifecycleError::Failed(error.reason));
3920                }
3921                Err(error) => {
3922                    if issued >= self.retry.max_attempts.max(1)
3923                        || !self.ports.demand.persists(policy.id).await
3924                    {
3925                        return Err(LifecycleError::Failed(error.reason));
3926                    }
3927                    let delay = error
3928                        .retry_after
3929                        .unwrap_or_else(|| self.retry.delay(issued));
3930                    self.ports.events.emit(AttemptEvent::Retry {
3931                        attempt,
3932                        operation: "jit_request",
3933                        delay,
3934                    });
3935                    self.ports.delay.wait(delay).await;
3936                    if !self.ports.demand.persists(policy.id).await {
3937                        return Err(LifecycleError::Failed(error.reason));
3938                    }
3939                }
3940            }
3941        }
3942    }
3943
3944    /// Where one attempt's files go, decided while the host allocation lock is
3945    /// held and before anything external happens.
3946    ///
3947    /// The branch is on the *repository's configured* workspace policy, so an
3948    /// organization policy and an ephemeral repository never reach slot
3949    /// selection at all: a persistent policy is unrepresentable for an
3950    /// organization target (D7, refused by `WorkspacePolicy::permitted_for` in
3951    /// both the constructor and the loader), and an ephemeral repository takes
3952    /// the disposable arm that existed before slots did.
3953    fn allocate_workspace(
3954        &self,
3955        policy: &ScalePolicy,
3956        id: AttemptId,
3957    ) -> Result<Placement, LifecycleError> {
3958        let placement = match policy.workspace_policy() {
3959            // Precedence (`02-target-architecture.md`): the repository's
3960            // persistent root is selected *before* the host root, which is why
3961            // this arm is first and why it never makes resolving the host
3962            // default a precondition of its own success.
3963            WorkspacePolicy::Persistent { root } => self.allocate_persistent_slot(policy, root),
3964            WorkspacePolicy::Ephemeral => self.allocate_disposable(policy, id),
3965        };
3966        // Here rather than inside the two arms, so that every path a root can
3967        // accept clears the record and none can be forgotten.
3968        if placement.is_ok() {
3969            self.root_accepted(policy.id);
3970        }
3971        placement
3972    }
3973
3974    /// `Host.runner_root_override`, read from the journal.
3975    ///
3976    /// Separated from [`Self::effective_host_root`] so that the two failures it
3977    /// folds together stay apart: an unreadable or missing host row is a journal
3978    /// problem and is always fatal, while an unresolvable *platform default* is
3979    /// only fatal to a placement that actually needs the host root.
3980    fn configured_host_root(&self) -> Result<Option<LocalAbsolutePath>, LifecycleError> {
3981        let host = self
3982            .ports
3983            .store
3984            .host(self.host_id)
3985            .map_err(|_| LifecycleError::Journal)?
3986            .ok_or_else(|| LifecycleError::Failed(FailureReason::Other("host not found".into())))?;
3987        Ok(host.runner_root_override.clone())
3988    }
3989
3990    /// `Host.runner_root_override`, or the platform default when that resolves;
3991    /// `None` when neither does. Unlike [`Self::effective_host_root`] it records
3992    /// no refusal, for callers to whom an unresolvable root is not a failure.
3993    fn resolvable_host_root(&self) -> Result<Option<LocalAbsolutePath>, LifecycleError> {
3994        Ok(self
3995            .configured_host_root()?
3996            .or_else(|| default_runner_root(&self.app_paths).ok()))
3997    }
3998
3999    /// `Host.runner_root_override`, or the platform default standing in for it.
4000    ///
4001    /// Takes the policy because an unresolvable default is a refusal like any
4002    /// other, and the record it leaves is that policy's.
4003    fn effective_host_root(
4004        &self,
4005        policy: &ScalePolicy,
4006    ) -> Result<LocalAbsolutePath, LifecycleError> {
4007        match self.configured_host_root()? {
4008            Some(configured) => Ok(configured),
4009            None => default_runner_root(&self.app_paths).map_err(|error| {
4010                // No root resolved, so there is no path to name but the one the
4011                // platform would have produced.
4012                self.root_refused(policy.id, "the platform default runner root", error)
4013            }),
4014        }
4015    }
4016
4017    /// Turns a runner-root refusal into a failure, and leaves the sentence
4018    /// somewhere an operator can read it.
4019    ///
4020    /// Per policy, because the policies on a host do not share a fate: one with
4021    /// its own persistent root places runners while another on a withheld
4022    /// volume places none, and a host-wide record would have the first clear
4023    /// the second's on the same pass.
4024    ///
4025    /// The recording is best-effort and its failure is deliberately swallowed:
4026    /// this runs on the path that is already failing, and a host that cannot
4027    /// write a diagnostic file must still report the refusal it came to report.
4028    /// `service status` says so itself when the file is unreadable.
4029    fn root_refused(&self, policy: PolicyId, root: &str, error: RunnerRootError) -> LifecycleError {
4030        let _ = runner_manager_platform::service::record_runner_root_refusal(
4031            &self.app_paths,
4032            &policy.to_string(),
4033            self.ports.clock.now(),
4034            error.kind(),
4035            root,
4036            &error.to_string(),
4037        );
4038        root_failure(error)
4039    }
4040
4041    /// Clears that policy's record, because its root accepted a placement.
4042    ///
4043    /// Called on every successful placement rather than only after a failure:
4044    /// the daemon that recovers is often not the process that failed -- a
4045    /// self-update restarts it -- so "clear it if we wrote it" would leave a
4046    /// stale note on `service status` for as long as the host ran.
4047    fn root_accepted(&self, policy: PolicyId) {
4048        let _ = runner_manager_platform::service::clear_runner_root_refusal(
4049            &self.app_paths,
4050            &policy.to_string(),
4051        );
4052    }
4053
4054    /// D3's disposable placement: a unique child of the effective host root,
4055    /// removed whole on cleanup. `c1`'s behaviour, moved behind the branch.
4056    fn allocate_disposable(
4057        &self,
4058        policy: &ScalePolicy,
4059        id: AttemptId,
4060    ) -> Result<Placement, LifecycleError> {
4061        let effective_root = self.effective_host_root(policy)?;
4062        RootPreflight::new(&self.app_paths)
4063            .check(&RootOwner::Host, &effective_root)
4064            .map_err(|error| self.root_refused(policy.id, effective_root.as_str(), error))?;
4065        let runtime = effective_root.as_path().join({
4066            #[cfg(test)]
4067            {
4068                if std::env::var("RUNNER_MANAGER_TEST_MUTANT").as_deref()
4069                    == Ok("reuse_job_workspace")
4070                {
4071                    "mutant-shared-workspace".to_owned()
4072                } else {
4073                    workspace_name(id)
4074                }
4075            }
4076            #[cfg(not(test))]
4077            {
4078                workspace_name(id)
4079            }
4080        });
4081        fs::create_dir_all(&runtime)
4082            .map_err(|_| LifecycleError::Failed(FailureReason::ProcessStartFailed))?;
4083        Ok(Placement {
4084            runtime,
4085            workspace: AttemptWorkspace::Ephemeral,
4086        })
4087    }
4088
4089    /// D4/D5's persistent placement: the lowest free `sN` under the repository's
4090    /// configured root.
4091    ///
4092    /// Steps 1 to 6 of `02-target-architecture.md`, "Slot allocation", in order;
4093    /// step 7 is the journal write [`Self::record_allocation`] owns. All of them
4094    /// run under the host allocation lock, because [`Self::launch_attempt`] is
4095    /// reachable only through a `LaunchRequest` and that carries the guard.
4096    ///
4097    /// **The filesystem is never consulted to decide which slots are taken**
4098    /// (invariant 6). The leases come from the journal; the directory is
4099    /// inspected only to decide whether *this* slot is safe to reuse.
4100    fn allocate_persistent_slot(
4101        &self,
4102        policy: &ScalePolicy,
4103        root: &LocalAbsolutePath,
4104    ) -> Result<Placement, LifecycleError> {
4105        // 1-4. The lowest positive slot no uncleaned attempt holds, refused
4106        // above the policy ceiling.
4107        let ceiling = policy.max_capacity().ok_or_else(|| {
4108            LifecycleError::Failed(FailureReason::Other(
4109                "a persistent workspace needs the policy's max_capacity to bound its slots"
4110                    .to_string(),
4111            ))
4112        })?;
4113        let leases = self
4114            .ports
4115            .store
4116            .slot_leases_for_policy(policy.id)
4117            .map_err(|_| LifecycleError::Journal)?;
4118        let slot = lowest_free_slot(&leases, ceiling).ok_or_else(|| {
4119            LifecycleError::Failed(FailureReason::Other(format!(
4120                "every persistent slot s1 to s{ceiling} for {} is leased by an attempt that has \
4121                 not been cleaned, so no slot is free; raise the repository's max capacity, or \
4122                 finish cleaning a concluded attempt",
4123                policy.target
4124            )))
4125        })?;
4126        let workspace = AttemptWorkspace::persistent_slot(slot);
4127        let name = workspace
4128            .slot_directory_name()
4129            .expect("a persistent allocation names its slot directory");
4130
4131        // The operational preflight, for the reasons the host root gets one: a
4132        // root that is remote, unwritable, or overlapping application data has
4133        // to fail before a directory is created rather than after. The host
4134        // root is registered only as something *not* to overlap; a host default
4135        // that cannot be resolved is a host-root problem and does not block a
4136        // repository that configured a root of its own.
4137        //
4138        // Only *that* failure is tolerated. An unreadable host row is a journal
4139        // failure and propagates, because silently continuing would drop the
4140        // overlap check entirely and accept a repository root that sits inside
4141        // the host root — the pair `RootPreflight` exists to refuse.
4142        let host_root = self.resolvable_host_root()?;
4143        let mut preflight = RootPreflight::new(&self.app_paths);
4144        if let Some(host_root) = host_root {
4145            preflight = preflight.against(RootOwner::Host, host_root);
4146        }
4147        let checked = preflight
4148            .check(&RootOwner::Repository(policy.target.to_string()), root)
4149            .map_err(|error| self.root_refused(policy.id, root.as_str(), error))?;
4150        if let Some(leaf) = checked.leaf_to_create() {
4151            fs::create_dir(leaf).map_err(|source| {
4152                LifecycleError::Failed(FailureReason::Other(format!(
4153                    "the persistent workspace root {} could not be created: {source}",
4154                    leaf.display()
4155                )))
4156            })?;
4157        }
4158
4159        // 5-6. `<root>/sN`, contained lexically by construction, then created or
4160        // validated, then contained canonically now that it resolves, and only
4161        // then accepted for reuse.
4162        let slot_path = runner_root::derive_child(root, &name)
4163            .map_err(|error| self.root_refused(policy.id, root.as_str(), error))?;
4164        create_or_validate_slot(slot_path.as_path())?;
4165        runner_root::verify_containment(root, &slot_path)
4166            .map_err(|error| self.root_refused(policy.id, root.as_str(), error))?;
4167        accept_reusable_slot(slot_path.as_path())?;
4168        Ok(Placement {
4169            runtime: slot_path.as_path().to_path_buf(),
4170            workspace,
4171        })
4172    }
4173
4174    /// The first journal write of an attempt, where a duplicate slot lease is
4175    /// still possible and has to be reported as itself.
4176    ///
4177    /// [`Self::record`] flattens every store failure into
4178    /// [`LifecycleError::Journal`], which is right for a state transition and
4179    /// wrong here: the partial unique index
4180    /// `one_uncleaned_persistent_attempt_per_slot` is the final race fence
4181    /// (`04-security-recovery.md`, "two attempts use one slot concurrently"),
4182    /// and an operator who reaches it needs to read that rather than "attempt
4183    /// journal operation failed". Nothing was written, so the caller returns
4184    /// without concluding an attempt that is not in the journal.
4185    fn record_allocation(&self, attempt: &RunnerAttempt) -> Result<(), LifecycleError> {
4186        match self.ports.store.record_attempt(attempt) {
4187            Ok(()) => {
4188                self.ports.events.emit(AttemptEvent::State {
4189                    attempt: attempt.id,
4190                    state: attempt.state(),
4191                });
4192                Ok(())
4193            }
4194            Err(error @ StoreError::SlotAlreadyLeased { .. }) => Err(LifecycleError::Failed(
4195                FailureReason::Other(error.to_string()),
4196            )),
4197            Err(_) => Err(LifecycleError::Journal),
4198        }
4199    }
4200
4201    async fn launch_attempt(
4202        &self,
4203        policy: &ScalePolicy,
4204        allocation_guard: &AllocationGuard,
4205        lock_wait: Duration,
4206    ) -> Result<RunnerAttempt, LifecycleError> {
4207        let (attempt, labels) = self.allocate_attempt(policy)?;
4208        let id = attempt.id;
4209        let mut stopwatch = LaunchStopwatch::new(lock_wait);
4210        let result = self
4211            .launch_steps(policy, allocation_guard, attempt, labels, &mut stopwatch)
4212            .await;
4213        stopwatch.finish();
4214        self.ports.events.emit(AttemptEvent::Launched {
4215            attempt: id,
4216            started: result.is_ok(),
4217            timings: stopwatch.timings,
4218        });
4219        result
4220    }
4221
4222    /// Place and journal a new attempt: everything before its first external
4223    /// effect, and so before there is anything to time.
4224    fn allocate_attempt<'p>(
4225        &self,
4226        policy: &'p ScalePolicy,
4227    ) -> Result<(RunnerAttempt, &'p RoutingLabels), LifecycleError> {
4228        if !*self
4229            .recovery_complete
4230            .lock()
4231            .map_err(|_| LifecycleError::Journal)?
4232        {
4233            return Err(LifecycleError::RecoveryIncomplete);
4234        }
4235        let capability = self.ports.processes.probe(policy);
4236        if capability != ProviderCapability::Ready {
4237            return Err(LifecycleError::Failed(capability.refusal()));
4238        }
4239        let resolved = self.ports.processes.resolve(policy).map_err(|reason| {
4240            LifecycleError::Failed(if policy.execution_policy().is_native() {
4241                reason
4242            } else {
4243                safe_isolation_reason(reason, IsolationProviderFailure::RuntimeOperationFailed)
4244            })
4245        })?;
4246        if policy.execution_policy().is_native() != resolved.is_none() {
4247            return Err(LifecycleError::Failed(FailureReason::Other(
4248                "provider resolution mismatch".into(),
4249            )));
4250        }
4251        if let Some(resolution) = &resolved {
4252            if resolution.provider_kind == Backend::Auto {
4253                return Err(LifecycleError::Failed(FailureReason::Other(
4254                    "provider resolution mismatch".into(),
4255                )));
4256            }
4257            AttemptExecution::Isolated {
4258                provider_kind: resolution.provider_kind,
4259                environment_id: None,
4260                resolved_image: resolution.image.clone(),
4261                generation: "generation-check".into(),
4262            }
4263            .validate()
4264            .map_err(|_| {
4265                LifecycleError::Failed(FailureReason::Other(
4266                    "resolved image is incompatible".into(),
4267                ))
4268            })?;
4269        }
4270        let labels = policy
4271            .routing_labels()
4272            .ok_or(LifecycleError::Failed(FailureReason::JitRequestFailed))?;
4273        let id = AttemptId::new_random();
4274        let placement = self.allocate_workspace(policy, id)?;
4275        let mut attempt = RunnerAttempt::allocate_in(
4276            id,
4277            policy.id,
4278            placement.runtime,
4279            placement.workspace,
4280            self.ports.clock.now(),
4281        );
4282        if let Some(resolution) = &resolved {
4283            attempt
4284                .allocate_execution(AttemptExecution::Isolated {
4285                    provider_kind: resolution.provider_kind,
4286                    environment_id: None,
4287                    resolved_image: resolution.image.clone(),
4288                    generation: uuid::Uuid::new_v4().to_string(),
4289                })
4290                .map_err(|_| LifecycleError::Transition)?;
4291        }
4292        // This is deliberately the first effect after directory allocation, and
4293        // for a persistent attempt it is also what makes the slot lease durable
4294        // before any package or GitHub effect
4295        // (`02-target-architecture.md`, "Slot allocation", step 7).
4296        self.record_allocation(&attempt)?;
4297        Ok((attempt, labels))
4298    }
4299
4300    async fn launch_steps(
4301        &self,
4302        policy: &ScalePolicy,
4303        allocation_guard: &AllocationGuard,
4304        mut attempt: RunnerAttempt,
4305        labels: &RoutingLabels,
4306        stopwatch: &mut LaunchStopwatch,
4307    ) -> Result<RunnerAttempt, LifecycleError> {
4308        let id = attempt.id;
4309        let isolated = !attempt.execution().is_native();
4310        stopwatch.begin(LaunchStep::Materialize);
4311        let version = match self.materialize_with_retry(policy, &attempt).await {
4312            Ok(version) => version,
4313            Err(reason) => return self.fail_launch(&mut attempt, reason),
4314        };
4315        self.prune_under_allocation_lock(allocation_guard, &version)?;
4316        self.versions
4317            .lock()
4318            .map_err(|_| LifecycleError::Journal)?
4319            .insert(id, version);
4320
4321        if isolated {
4322            attempt
4323                .begin_prepare(self.ports.clock.now())
4324                .map_err(|_| LifecycleError::Transition)?;
4325            self.record(&attempt)?;
4326        }
4327        let prepared = match self.ports.processes.prepare(&attempt, policy) {
4328            Ok(prepared) => prepared,
4329            Err(reason) => {
4330                let safe_reason = if isolated {
4331                    safe_isolation_reason(reason, IsolationProviderFailure::RuntimeOperationFailed)
4332                } else {
4333                    reason
4334                };
4335                return self.fail_launch(&mut attempt, safe_reason);
4336            }
4337        };
4338        if isolated {
4339            let Some(identity) = prepared.identity() else {
4340                return Err(LifecycleError::Failed(FailureReason::IsolationProvider(
4341                    IsolationProviderFailure::OwnershipMismatch,
4342                )));
4343            };
4344            if !identity_matches_intent(self.host_id, &attempt, identity) {
4345                return Err(LifecycleError::Failed(FailureReason::IsolationProvider(
4346                    IsolationProviderFailure::OwnershipMismatch,
4347                )));
4348            }
4349            let EnvironmentIdentity::Isolated { environment_id, .. } = identity else {
4350                unreachable!()
4351            };
4352            attempt
4353                .prepared_environment(environment_id.clone())
4354                .map_err(|_| LifecycleError::Transition)?;
4355            attempt
4356                .mark_prepared(self.ports.clock.now())
4357                .map_err(|_| LifecycleError::Transition)?;
4358            self.record(&attempt)?;
4359        }
4360
4361        stopwatch.begin(LaunchStep::Register);
4362        let jit_request =
4363            JitRunnerRequest::for_policy(runner_name(id), self.runner_group_id, labels);
4364        let registration = match self.register_with_retry(policy, id, &jit_request).await {
4365            Ok(registration) => registration,
4366            Err(error) => return self.fail_launch(&mut attempt, error.reason()),
4367        };
4368        stopwatch.begin(LaunchStep::Spawn);
4369        let runner_id = registration.runner().id;
4370        write_runner_id(attempt.runtime_path(), runner_id)?;
4371        attempt
4372            .jit_received(self.ports.clock.now())
4373            .map_err(|_| LifecycleError::Transition)?;
4374        self.record(&attempt)?;
4375        let config = registration.into_config();
4376        if isolated {
4377            let started = self
4378                .ports
4379                .processes
4380                .start(prepared, &attempt, OneTimeJitHandoff::new(&config))
4381                .map_err(|failure| {
4382                    LifecycleError::Failed(safe_isolation_reason(
4383                        failure.reason,
4384                        IsolationProviderFailure::RuntimeOperationFailed,
4385                    ))
4386                })?;
4387            if !identity_matches_intent(self.host_id, &attempt, &started) {
4388                return Err(LifecycleError::Failed(FailureReason::IsolationProvider(
4389                    IsolationProviderFailure::OwnershipMismatch,
4390                )));
4391            }
4392            attempt
4393                .started_isolated(self.ports.clock.now())
4394                .map_err(|_| LifecycleError::Transition)?;
4395            self.record(&attempt)?;
4396            return Ok(attempt);
4397        }
4398        let mut issued = 0_u32;
4399        let pid = loop {
4400            issued = issued.saturating_add(1);
4401            match self.ports.processes.start(
4402                prepared.clone(),
4403                &attempt,
4404                OneTimeJitHandoff::new(&config),
4405            ) {
4406                Ok(EnvironmentIdentity::NativeProcess(pid)) => break pid,
4407                Ok(EnvironmentIdentity::Isolated { .. }) => {
4408                    // No isolated launch sequence is wired in this task. Keep
4409                    // the nonterminal JIT journal as a capacity fence rather
4410                    // than treating an unexpected resource as a native PID.
4411                    return Err(LifecycleError::Failed(FailureReason::Other(
4412                        "unexpected isolated environment identity".into(),
4413                    )));
4414                }
4415                Err(error) => {
4416                    if let Some(pid) = error.live_pid {
4417                        attempt
4418                            .started(pid, self.ports.clock.now())
4419                            .map_err(|_| LifecycleError::Transition)?;
4420                        self.record(&attempt)?;
4421                        return Err(LifecycleError::Failed(error.reason));
4422                    }
4423                    if !error.retryable
4424                        || issued >= self.retry.max_attempts.max(1)
4425                        || !self.ports.demand.persists(policy.id).await
4426                    {
4427                        return self.fail_launch(&mut attempt, error.reason);
4428                    }
4429                    let delay = self.retry.delay(issued);
4430                    self.ports.events.emit(AttemptEvent::Retry {
4431                        attempt: attempt.id,
4432                        operation: "process_start",
4433                        delay,
4434                    });
4435                    self.ports.delay.wait(delay).await;
4436                    if !self.ports.demand.persists(policy.id).await {
4437                        return self.fail_launch(&mut attempt, error.reason);
4438                    }
4439                }
4440            }
4441        };
4442        attempt
4443            .started(pid, self.ports.clock.now())
4444            .map_err(|_| LifecycleError::Transition)?;
4445        self.record(&attempt)?;
4446        Ok(attempt)
4447    }
4448
4449    fn fail_launch<T>(
4450        &self,
4451        attempt: &mut RunnerAttempt,
4452        reason: FailureReason,
4453    ) -> Result<T, LifecycleError> {
4454        self.conclude(attempt, AttemptOutcome::failed(reason.clone()))?;
4455        Err(LifecycleError::Failed(reason))
4456    }
4457
4458    /// e2's prune guard is invoked only with e1's allocation guard borrowed.
4459    /// The otherwise-unused argument is a compile-time witness of the ordering.
4460    fn prune_under_allocation_lock(
4461        &self,
4462        guard: &AllocationGuard,
4463        version: &RunnerVersion,
4464    ) -> Result<(), LifecycleError> {
4465        let attempts = self
4466            .ports
4467            .store
4468            .attempts()
4469            .map_err(|_| LifecycleError::Journal)?;
4470        self.ports
4471            .packages
4472            .prune_obsolete_guarded(
4473                PruneAuthority::from_launch_request(guard),
4474                version,
4475                &attempts,
4476            )
4477            .map_err(LifecycleError::Failed)?;
4478        #[cfg(target_os = "macos")]
4479        self.prune_abandoned_seeds(&attempts);
4480        Ok(())
4481    }
4482
4483    /// Remove the package seed from every runner root this host once placed a
4484    /// runtime in and no longer does: a changed host root override, a removed
4485    /// persistent policy, or one that moved its workspace.
4486    ///
4487    /// The roots ever used are the parents of the journal's runtime paths; the
4488    /// roots in use are the effective host root and every persistent policy's
4489    /// root. When the effective host root cannot be resolved nothing is
4490    /// removed, because a seed still in use cannot then be told apart. Runs
4491    /// under the allocation lock, so no launch is cloning from a seed it
4492    /// removes, and it is best-effort: a seed left behind costs disk, never a
4493    /// launch.
4494    #[cfg(target_os = "macos")]
4495    fn prune_abandoned_seeds(&self, attempts: &[RunnerAttempt]) {
4496        let Ok(policies) = self.ports.store.policies() else {
4497            return;
4498        };
4499        let Ok(Some(host_root)) = self.resolvable_host_root() else {
4500            return;
4501        };
4502        let in_use: BTreeSet<PathBuf> = policies
4503            .iter()
4504            .filter_map(|policy| match policy.workspace_policy() {
4505                WorkspacePolicy::Persistent { root } => Some(canonical_or_raw(root.as_path())),
4506                WorkspacePolicy::Ephemeral => None,
4507            })
4508            .chain(std::iter::once(canonical_or_raw(host_root.as_path())))
4509            .collect();
4510        // Distinct parents first: the journal keeps every attempt ever made.
4511        let used: BTreeSet<&Path> = attempts
4512            .iter()
4513            .filter_map(|attempt| attempt.runtime_path().parent())
4514            .collect();
4515        for root in used.into_iter().map(canonical_or_raw) {
4516            if !in_use.contains(&root) {
4517                let _ = remove_runtime_tree(&root.join(PACKAGE_SEED_DIR));
4518            }
4519        }
4520    }
4521}
4522
4523#[cfg(target_os = "macos")]
4524fn canonical_or_raw(path: &Path) -> PathBuf {
4525    fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
4526}
4527
4528#[async_trait]
4529impl RunnerLauncher for LifecycleLauncher {
4530    async fn supervise(
4531        &self,
4532        policy: &ScalePolicy,
4533    ) -> Result<Vec<ReplacementIntent>, LaunchFailure> {
4534        LifecycleLauncher::supervise(self, policy)
4535            .await
4536            .map_err(|error| LaunchFailure::new(error.reason()))
4537    }
4538
4539    async fn attempts(&self) -> Result<Vec<RunnerAttempt>, LaunchFailure> {
4540        self.ports.store.attempts().map_err(|_| {
4541            LaunchFailure::new(FailureReason::Other(
4542                "attempt journal could not be read".into(),
4543            ))
4544        })
4545    }
4546
4547    async fn launch(&self, request: LaunchRequest<'_>) -> Result<RunnerAttempt, LaunchFailure> {
4548        self.launch_attempt(request.policy, request.allocation_guard, request.lock_wait)
4549            .await
4550            .map_err(|error| LaunchFailure::new(error.reason()))
4551    }
4552
4553    async fn clean(&self, id: AttemptId) -> Result<(), LaunchFailure> {
4554        let mut attempt = self
4555            .ports
4556            .store
4557            .attempt(id)
4558            .map_err(|_| {
4559                LaunchFailure::new(FailureReason::Other(
4560                    "attempt journal could not be read".into(),
4561                ))
4562            })?
4563            .ok_or_else(|| {
4564                LaunchFailure::new(FailureReason::Other(
4565                    "attempt disappeared from the journal".into(),
4566                ))
4567            })?;
4568        self.clean_attempt(&mut attempt)
4569            .map_err(|error| LaunchFailure::new(error.reason()))?;
4570        self.cleanup_retries().succeeded(id);
4571        Ok(())
4572    }
4573}
4574
4575fn runner_name(attempt: AttemptId) -> String {
4576    format!("runner-manager-{attempt}")
4577}
4578
4579fn read_runner_id(runtime: &Path) -> Option<u64> {
4580    fs::read_to_string(runtime.join(RUNNER_ID_FILE))
4581        .ok()?
4582        .trim()
4583        .parse()
4584        .ok()
4585}
4586
4587fn write_runner_id(runtime: &Path, runner_id: u64) -> Result<(), LifecycleError> {
4588    let target = runtime.join(RUNNER_ID_FILE);
4589    if let Some(existing) = read_runner_id(runtime) {
4590        return (existing == runner_id)
4591            .then_some(())
4592            .ok_or(LifecycleError::Journal);
4593    }
4594    let temporary = runtime.join(format!("{RUNNER_ID_FILE}.{}.tmp", uuid::Uuid::new_v4()));
4595    write_durable_file(&temporary, runner_id.to_string().as_bytes())
4596        .map_err(|_| LifecycleError::Journal)?;
4597    match fs::rename(&temporary, &target) {
4598        Ok(()) => sync_directory(runtime).map_err(|_| LifecycleError::Journal),
4599        Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
4600            let _ = fs::remove_file(&temporary);
4601            (read_runner_id(runtime) == Some(runner_id))
4602                .then_some(())
4603                .ok_or(LifecycleError::Journal)
4604        }
4605        Err(_) => {
4606            let _ = fs::remove_file(&temporary);
4607            Err(LifecycleError::Journal)
4608        }
4609    }
4610}
4611
4612fn write_durable_file(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
4613    let mut file = fs::OpenOptions::new()
4614        .create(true)
4615        .truncate(true)
4616        .write(true)
4617        .open(path)?;
4618    file.write_all(bytes)?;
4619    file.sync_all()?;
4620    let parent = path.parent().ok_or_else(|| {
4621        std::io::Error::new(
4622            std::io::ErrorKind::InvalidInput,
4623            "file has no parent directory",
4624        )
4625    })?;
4626    sync_directory(parent)
4627}
4628
4629#[cfg(unix)]
4630fn sync_directory(path: &Path) -> std::io::Result<()> {
4631    fs::File::open(path)?.sync_all()
4632}
4633
4634#[cfg(windows)]
4635fn sync_directory(path: &Path) -> std::io::Result<()> {
4636    use std::os::windows::fs::OpenOptionsExt;
4637
4638    const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000;
4639    const FILE_SHARE_ALL: u32 = 0x0000_0007;
4640    const GENERIC_WRITE: u32 = 0x4000_0000;
4641    fs::OpenOptions::new()
4642        .access_mode(GENERIC_WRITE)
4643        .share_mode(FILE_SHARE_ALL)
4644        .custom_flags(FILE_FLAG_BACKUP_SEMANTICS)
4645        .open(path)?
4646        .sync_all()
4647}
4648
4649#[cfg(test)]
4650mod tests {
4651    use super::*;
4652    use std::collections::BTreeSet;
4653    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
4654
4655    use crate::reconcile::{AllocationLock, InProcessAllocationLock};
4656    use runner_manager_domain::model::{Elapsed, TargetScope};
4657    use runner_manager_domain::store::SqliteStore;
4658    use runner_manager_github::jit::JitRunner;
4659    use runner_manager_testkit::clock::FakeClock;
4660    use runner_manager_testkit::fixtures;
4661
4662    #[test]
4663    fn production_provider_set_routes_every_implemented_isolated_backend() {
4664        let providers = PlatformExecutionProvider::new(HostId::from_u128(1));
4665        assert!(providers.isolated_provider(Backend::Auto).is_some());
4666        assert!(providers.isolated_provider(Backend::Oci).is_some());
4667        assert!(
4668            providers
4669                .isolated_provider(Backend::WindowsHyperVContainer)
4670                .is_some()
4671        );
4672        assert!(
4673            providers
4674                .isolated_provider(Backend::VirtualMachine)
4675                .is_none()
4676        );
4677    }
4678
4679    /// One event's fields, in the order they were recorded.
4680    type CapturedFields = Vec<(String, String)>;
4681
4682    /// Keeps the `(name, value)` fields of every `tracing` event emitted while
4683    /// it is installed.
4684    ///
4685    /// The names are kept and not just the rendered line, so a test can hold the
4686    /// event to `crate::logging`'s two rules -- the field allow-list and the
4687    /// value scrub -- instead of asserting that some string was passed to a
4688    /// macro.
4689    #[derive(Clone, Default)]
4690    struct CapturedEvents(std::sync::Arc<std::sync::Mutex<Vec<CapturedFields>>>);
4691
4692    impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for CapturedEvents {
4693        fn on_event(
4694            &self,
4695            event: &tracing::Event<'_>,
4696            _context: tracing_subscriber::layer::Context<'_, S>,
4697        ) {
4698            struct Collect(Vec<(String, String)>);
4699            impl tracing::field::Visit for Collect {
4700                fn record_debug(
4701                    &mut self,
4702                    field: &tracing::field::Field,
4703                    value: &dyn std::fmt::Debug,
4704                ) {
4705                    // `{value:?}` on a `&str` field would keep the quotes, and
4706                    // the redaction rules are about the value, not its literal.
4707                    self.0.push((
4708                        field.name().to_owned(),
4709                        format!("{value:?}").trim_matches('"').to_owned(),
4710                    ));
4711                }
4712            }
4713            let mut collected = Collect(Vec::new());
4714            event.record(&mut collected);
4715            self.0
4716                .lock()
4717                .expect("the capture mutex is not poisoned")
4718                .push(collected.0);
4719        }
4720    }
4721
4722    /// The regression behind a three-hour outage that showed nothing an operator
4723    /// could act on.
4724    ///
4725    /// A root the daemon cannot use refuses the launch *before*
4726    /// `record_allocation`, so no attempt row is ever written: `b2` has nothing
4727    /// to carry the failure on and `g2` has nothing to show it from. The
4728    /// lifecycle event keeps only the variant -- `failure_reason_kind` allows no
4729    /// free text on an event -- so the whole failure read
4730    /// `runner_start_failed reason=other`, once per poll, for hours.
4731    ///
4732    /// The assertion is deliberately made against the **production redaction
4733    /// rules** and not merely against what was emitted. An earlier attempt at
4734    /// this fix logged the rendered error on a `detail` field and passed a test
4735    /// exactly like this one, while shipping `detail="[redacted]"`: the field
4736    /// was not allow-listed, and had it been, the value is mostly paths and
4737    /// would have been scrubbed to `[path]`. A test that does not ask
4738    /// `crate::logging` what survives is a test that proves nothing.
4739    #[test]
4740    fn a_launch_the_runner_root_refused_names_the_cause_in_the_log_that_ships() {
4741        use runner_manager_platform::logging;
4742        use tracing_subscriber::layer::SubscriberExt as _;
4743
4744        let captured = CapturedEvents::default();
4745        let error = RunnerRootError::DeniedByPrivacyPolicy {
4746            requested: PathBuf::from("/Volumes/NVME/runners"),
4747            refused: PathBuf::from("/Volumes/NVME"),
4748            remediation: RootOwner::Host.remediation(),
4749        };
4750        let kind = error.kind();
4751
4752        let failure = tracing::subscriber::with_default(
4753            tracing_subscriber::registry().with(captured.clone()),
4754            || root_failure(error),
4755        );
4756
4757        // The reason still carries the whole sentence. Nothing on *this* path
4758        // renders it -- there is no attempt row, and the event carries only the
4759        // variant -- so this is a guard against a refactor that drops the detail
4760        // before some future surface can show it, and not a claim that one does.
4761        assert!(
4762            matches!(
4763                &failure,
4764                LifecycleError::Failed(FailureReason::Other(detail))
4765                    if detail.contains("/Volumes/NVME/runners")
4766                        && detail.contains("Full Disk Access")
4767            ),
4768            "the reason must still carry the detail: {failure:?}"
4769        );
4770
4771        let events = captured
4772            .0
4773            .lock()
4774            .expect("the capture mutex is not poisoned")
4775            .clone();
4776        let event = events
4777            .iter()
4778            .find(|fields| fields.iter().any(|(_, value)| value.contains(kind)))
4779            .unwrap_or_else(|| panic!("the refusal did not name its cause: {events:?}"));
4780
4781        // Held to the rules the real sink applies, by name and by value. An
4782        // earlier fix put the rendered error on an unlisted `detail` field and
4783        // shipped `[redacted]`; asserting only that something was emitted would
4784        // have passed then too.
4785        for (name, value) in event {
4786            assert!(
4787                logging::is_field_allowed(name),
4788                "`{name}` is not allow-listed, so it ships as `{}`: {event:?}",
4789                logging::REDACTION
4790            );
4791            assert_eq!(
4792                &logging::redact(value),
4793                value,
4794                "`{name}` does not survive value-shape scrubbing: {event:?}"
4795            );
4796        }
4797    }
4798
4799    /// A slot number, for the tests that name one.
4800    fn nz(slot: u16) -> NonZeroU16 {
4801        NonZeroU16::new(slot).expect("a positive slot")
4802    }
4803
4804    const JIT: &str = "eyJzZWNyZXQiOiJnaHBfRE9fTk9UX0xFQUsifQ==";
4805
4806    #[derive(Debug, Default)]
4807    struct FakeGithubLifecycle {
4808        registration_failures: Mutex<VecDeque<bool>>,
4809        observations: Mutex<VecDeque<LifecycleGithubObservation>>,
4810        registrations: AtomicUsize,
4811        remaining_runners: AtomicUsize,
4812        /// Every runner id `deregister` was asked to remove, in order. A count
4813        /// would not do: the assertions worth making are that the *right*
4814        /// registration was deleted and that it was deleted once.
4815        deregistrations: Mutex<Vec<u64>>,
4816        /// Set to make `deregister` answer `false`, standing for a GitHub that
4817        /// could not be reached at the moment the attempt concluded.
4818        deregistration_fails: AtomicBool,
4819        /// The journal to read *during* a registration, for the ordering
4820        /// assertion `02-target-architecture.md` makes: the slot lease is
4821        /// written "before package or GitHub effects". Reading it afterwards
4822        /// would pass even if the write happened second.
4823        journal: Mutex<Option<Arc<SqliteStore>>>,
4824        /// One entry per registration, in order.
4825        registration_facts: Mutex<Vec<RegistrationFact>>,
4826    }
4827
4828    /// What one JIT registration saw of the world at the moment it was issued.
4829    ///
4830    /// `runner_name` is what ties the other two fields to *one* attempt: with
4831    /// two allocators racing, "some slot was journalled" is a much weaker claim
4832    /// than "the slot this very request belongs to was journalled", and only the
4833    /// name distinguishes them.
4834    #[derive(Debug, Clone)]
4835    struct RegistrationFact {
4836        /// The persistent slots the journal already held.
4837        leased_slots: Vec<u16>,
4838        /// The `work_folder` the request carried.
4839        work_folder: String,
4840        /// The runner name the request carried, i.e. [`runner_name`] of the
4841        /// registering attempt.
4842        runner_name: String,
4843        journal_states: Vec<AttemptState>,
4844    }
4845
4846    impl FakeGithubLifecycle {
4847        fn fail(mut self, terminal: bool) -> Self {
4848            self.registration_failures
4849                .get_mut()
4850                .expect("unpoisoned")
4851                .push_back(terminal);
4852            self
4853        }
4854
4855        fn watch_journal(&self, store: Arc<SqliteStore>) {
4856            *self.journal.lock().unwrap() = Some(store);
4857        }
4858
4859        fn registration_facts(&self) -> Vec<RegistrationFact> {
4860            self.registration_facts.lock().unwrap().clone()
4861        }
4862
4863        fn observe(&self, observation: GithubRunnerObservation) {
4864            let observation = match observation {
4865                GithubRunnerObservation::Unreachable => LifecycleGithubObservation::unreachable(),
4866                GithubRunnerObservation::NotRegistered => {
4867                    LifecycleGithubObservation::not_registered()
4868                }
4869                GithubRunnerObservation::Registered { busy } => {
4870                    LifecycleGithubObservation::registered(73, busy)
4871                }
4872            };
4873            self.observations.lock().unwrap().push_back(observation);
4874        }
4875    }
4876
4877    #[async_trait]
4878    impl LifecycleGithub for FakeGithubLifecycle {
4879        async fn register(
4880            &self,
4881            _target: &ScaleTarget,
4882            request: &JitRunnerRequest,
4883            _cancel: &CancelToken,
4884        ) -> Result<JitRegistration, JitRequestFailure> {
4885            self.registrations.fetch_add(1, Ordering::SeqCst);
4886            if let Some(store) = self.journal.lock().unwrap().as_ref() {
4887                let attempts = store.attempts().expect("the journal is readable");
4888                let slots = attempts
4889                    .iter()
4890                    .filter_map(|attempt| attempt.workspace().slot_number())
4891                    .collect();
4892                self.registration_facts
4893                    .lock()
4894                    .unwrap()
4895                    .push(RegistrationFact {
4896                        leased_slots: slots,
4897                        work_folder: request.work_folder().to_string(),
4898                        runner_name: request.name().to_string(),
4899                        journal_states: attempts.iter().map(RunnerAttempt::state).collect(),
4900                    });
4901            }
4902            if let Some(terminal) = self.registration_failures.lock().unwrap().pop_front() {
4903                return Err(JitRequestFailure {
4904                    terminal,
4905                    reason: if terminal {
4906                        FailureReason::Other("GitHub refused JIT registration with 403".into())
4907                    } else {
4908                        FailureReason::JitRequestFailed
4909                    },
4910                    retry_after: None,
4911                });
4912            }
4913            self.remaining_runners.store(1, Ordering::SeqCst);
4914            Ok(JitRegistration::new(
4915                EncodedJitConfig::new(JIT),
4916                JitRunner {
4917                    id: 73,
4918                    name: request.name().to_string(),
4919                    os: "windows".into(),
4920                    status: "offline".into(),
4921                    busy: false,
4922                    runner_group_id: Some(1),
4923                    labels: request.labels().to_vec(),
4924                },
4925            ))
4926        }
4927
4928        async fn observe(
4929            &self,
4930            _target: &ScaleTarget,
4931            _attempt: AttemptId,
4932            _cancel: &CancelToken,
4933        ) -> LifecycleGithubObservation {
4934            let observation = self
4935                .observations
4936                .lock()
4937                .unwrap()
4938                .pop_front()
4939                .unwrap_or(LifecycleGithubObservation::not_registered());
4940            if observation.status == GithubRunnerObservation::NotRegistered {
4941                self.remaining_runners.store(0, Ordering::SeqCst);
4942            }
4943            observation
4944        }
4945
4946        async fn deregister(
4947            &self,
4948            _target: &ScaleTarget,
4949            runner_id: u64,
4950            _cancel: &CancelToken,
4951        ) -> bool {
4952            self.deregistrations.lock().unwrap().push(runner_id);
4953            if self.deregistration_fails.load(Ordering::SeqCst) {
4954                return false;
4955            }
4956            self.remaining_runners.store(0, Ordering::SeqCst);
4957            true
4958        }
4959    }
4960
4961    #[derive(Debug)]
4962    struct FakePackages {
4963        version: RunnerVersion,
4964        leases: Mutex<BTreeSet<AttemptId>>,
4965        materializations: AtomicUsize,
4966        materialization_failures: AtomicUsize,
4967        release_failures: AtomicUsize,
4968        releases: AtomicUsize,
4969        prunes: AtomicUsize,
4970        prune_currents: Mutex<Vec<RunnerVersion>>,
4971    }
4972
4973    impl Default for FakePackages {
4974        fn default() -> Self {
4975            Self {
4976                version: RunnerVersion::parse("2.330.0").unwrap(),
4977                leases: Mutex::new(BTreeSet::new()),
4978                materializations: AtomicUsize::new(0),
4979                materialization_failures: AtomicUsize::new(0),
4980                release_failures: AtomicUsize::new(0),
4981                releases: AtomicUsize::new(0),
4982                prunes: AtomicUsize::new(0),
4983                prune_currents: Mutex::new(Vec::new()),
4984            }
4985        }
4986    }
4987
4988    impl FakePackages {
4989        fn fail_materializations(&self, count: usize) {
4990            self.materialization_failures.store(count, Ordering::SeqCst);
4991        }
4992
4993        fn fail_releases(&self, count: usize) {
4994            self.release_failures.store(count, Ordering::SeqCst);
4995        }
4996    }
4997
4998    #[async_trait]
4999    impl RuntimePackages for FakePackages {
5000        async fn materialize(
5001            &self,
5002            attempt: &RunnerAttempt,
5003        ) -> Result<RunnerVersion, FailureReason> {
5004            self.materializations.fetch_add(1, Ordering::SeqCst);
5005            if self
5006                .materialization_failures
5007                .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |left| {
5008                    if left > 0 { Some(left - 1) } else { None }
5009                })
5010                .is_ok()
5011            {
5012                return Err(FailureReason::Other(
5013                    "runner package materialization failed transiently".into(),
5014                ));
5015            }
5016            fs::create_dir_all(attempt.runtime_path()).unwrap();
5017            fs::write(attempt.runtime_path().join("runner-package"), b"verified").unwrap();
5018            self.leases.lock().unwrap().insert(attempt.id);
5019            Ok(self.version.clone())
5020        }
5021
5022        fn release(&self, attempt: AttemptId) -> Result<(), FailureReason> {
5023            if self
5024                .release_failures
5025                .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |left| {
5026                    if left > 0 { Some(left - 1) } else { None }
5027                })
5028                .is_ok()
5029            {
5030                return Err(FailureReason::Other(
5031                    "runner package lease could not be released".into(),
5032                ));
5033            }
5034            self.leases.lock().unwrap().remove(&attempt);
5035            self.releases.fetch_add(1, Ordering::SeqCst);
5036            Ok(())
5037        }
5038
5039        fn prune_obsolete_guarded(
5040            &self,
5041            _authority: PruneAuthority<'_>,
5042            current: &RunnerVersion,
5043            _attempts: &[RunnerAttempt],
5044        ) -> Result<(), FailureReason> {
5045            self.prunes.fetch_add(1, Ordering::SeqCst);
5046            self.prune_currents.lock().unwrap().push(current.clone());
5047            Ok(())
5048        }
5049    }
5050
5051    #[derive(Debug, Default)]
5052    struct FakeProcesses {
5053        alive: AtomicBool,
5054        completed_successfully: AtomicBool,
5055        spawns: AtomicUsize,
5056        spawn_failures: AtomicUsize,
5057        live_spawn_failure: AtomicBool,
5058        terminations: AtomicUsize,
5059        intent: AtomicBool,
5060        intent_failure: AtomicBool,
5061        actions: Mutex<Vec<&'static str>>,
5062        saw_secret: AtomicBool,
5063    }
5064
5065    impl FakeProcesses {
5066        fn fail_spawns(&self, count: usize) {
5067            self.spawn_failures.store(count, Ordering::SeqCst);
5068        }
5069
5070        fn fail_spawn_with_live_child(&self) {
5071            self.live_spawn_failure.store(true, Ordering::SeqCst);
5072        }
5073
5074        fn set_alive(&self, alive: bool) {
5075            self.alive.store(alive, Ordering::SeqCst);
5076        }
5077
5078        fn finish_successfully(&self) {
5079            self.completed_successfully.store(true, Ordering::SeqCst);
5080            self.alive.store(false, Ordering::SeqCst);
5081        }
5082
5083        fn fail_intent(&self) {
5084            self.intent_failure.store(true, Ordering::SeqCst);
5085        }
5086    }
5087
5088    impl ProcessSupervisor for FakeProcesses {
5089        fn spawn(
5090            &self,
5091            attempt: &RunnerAttempt,
5092            config: &EncodedJitConfig,
5093        ) -> Result<u32, ProcessStartFailure> {
5094            self.spawns.fetch_add(1, Ordering::SeqCst);
5095            // Model the production handoff on both paths: the sensitive file is
5096            // scoped to this call and absent when it returns.
5097            let handoff = RestrictiveHandoff::create(
5098                attempt.runtime_path(),
5099                SecretString::from(config.expose().to_owned()),
5100            )
5101            .unwrap();
5102            self.saw_secret
5103                .store(config.expose() == JIT, Ordering::SeqCst);
5104            let handoff_path = handoff.path().to_path_buf();
5105            let failing = self
5106                .spawn_failures
5107                .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |left| {
5108                    if left > 0 { Some(left - 1) } else { None }
5109                })
5110                .is_ok();
5111            drop(handoff);
5112            assert!(!handoff_path.exists(), "handoff must be absent on return");
5113            if self.live_spawn_failure.swap(false, Ordering::SeqCst) {
5114                self.alive.store(true, Ordering::SeqCst);
5115                return Err(ProcessStartFailure::after_spawn_live(4242));
5116            }
5117            if failing {
5118                return Err(ProcessStartFailure::before_spawn(
5119                    FailureReason::ProcessStartFailed,
5120                ));
5121            }
5122            self.alive.store(true, Ordering::SeqCst);
5123            Ok(4242)
5124        }
5125
5126        fn is_alive(&self, _attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
5127            self.actions.lock().unwrap().push("observe_process");
5128            Ok(self.alive.load(Ordering::SeqCst))
5129        }
5130
5131        fn recovered_pid(&self, _attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason> {
5132            Ok(self.alive.load(Ordering::SeqCst).then_some(4242))
5133        }
5134
5135        fn completed_successfully(&self, _attempt: &RunnerAttempt) -> bool {
5136            self.completed_successfully.load(Ordering::SeqCst)
5137        }
5138
5139        fn record_terminate_intent(&self, _attempt: &RunnerAttempt) -> Result<(), FailureReason> {
5140            self.actions.lock().unwrap().push("terminate_intent");
5141            if self.intent_failure.load(Ordering::SeqCst) {
5142                return Err(FailureReason::Other(
5143                    "terminate intent directory sync failed".into(),
5144                ));
5145            }
5146            self.intent.store(true, Ordering::SeqCst);
5147            Ok(())
5148        }
5149
5150        fn has_terminate_intent(&self, _attempt: &RunnerAttempt) -> bool {
5151            self.intent.load(Ordering::SeqCst)
5152        }
5153
5154        fn terminate(&self, _attempt: &RunnerAttempt) -> Result<(), FailureReason> {
5155            assert!(
5156                self.intent.load(Ordering::SeqCst),
5157                "the durable intent must exist before signalling"
5158            );
5159            self.actions.lock().unwrap().push("terminate");
5160            self.terminations.fetch_add(1, Ordering::SeqCst);
5161            self.alive.store(false, Ordering::SeqCst);
5162            Ok(())
5163        }
5164    }
5165
5166    #[derive(Debug, Default)]
5167    struct FakeIsolatedProvider {
5168        resources: Mutex<BTreeMap<AttemptId, (EnvironmentIdentity, EnvironmentState)>>,
5169        extra_resources: Mutex<Vec<EnvironmentIdentity>>,
5170        unsupported_host: AtomicBool,
5171        permission_denied: AtomicBool,
5172        incompatible_image: AtomicBool,
5173        prepare_failure: AtomicBool,
5174        recovery_unavailable: AtomicBool,
5175        destroy_deferred: AtomicBool,
5176        handoffs: AtomicUsize,
5177        completed_successfully: AtomicBool,
5178        native_calls: AtomicUsize,
5179        actions: Mutex<Vec<&'static str>>,
5180    }
5181
5182    impl FakeIsolatedProvider {
5183        fn identity(attempt: &RunnerAttempt, host: HostId) -> EnvironmentIdentity {
5184            let AttemptExecution::Isolated {
5185                provider_kind,
5186                resolved_image,
5187                generation,
5188                ..
5189            } = attempt.execution()
5190            else {
5191                panic!("isolated intent required")
5192            };
5193            EnvironmentIdentity::Isolated {
5194                host,
5195                attempt: attempt.id,
5196                provider_kind: *provider_kind,
5197                environment_id: format!("environment-{}", attempt.id),
5198                resolved_image: resolved_image.clone(),
5199                generation: generation.clone(),
5200            }
5201        }
5202
5203        fn resource_count(&self) -> usize {
5204            self.resources.lock().unwrap().len()
5205        }
5206    }
5207
5208    impl ExecutionProvider for FakeIsolatedProvider {
5209        fn probe(&self, policy: &ScalePolicy) -> ProviderCapability {
5210            if self.unsupported_host.load(Ordering::SeqCst) {
5211                ProviderCapability::Unsupported
5212            } else if self.permission_denied.load(Ordering::SeqCst) {
5213                ProviderCapability::PermissionDenied
5214            } else if policy.execution_policy().is_native() {
5215                ProviderCapability::Unsupported
5216            } else {
5217                ProviderCapability::Ready
5218            }
5219        }
5220
5221        fn resolve(
5222            &self,
5223            policy: &ScalePolicy,
5224        ) -> Result<Option<ResolvedEnvironment>, FailureReason> {
5225            let runner_manager_domain::execution::ExecutionPolicy::Isolated { image, .. } =
5226                policy.execution_policy()
5227            else {
5228                return Err(FailureReason::Other("isolated policy required".into()));
5229            };
5230            Ok(Some(ResolvedEnvironment {
5231                provider_kind: Backend::Oci,
5232                image: if self.incompatible_image.load(Ordering::SeqCst) {
5233                    ImageReference::new("vm-version:wrong-provider")
5234                        .expect("valid but incompatible")
5235                } else {
5236                    image.clone()
5237                },
5238            }))
5239        }
5240
5241        fn prepare(
5242            &self,
5243            attempt: &RunnerAttempt,
5244            policy: &ScalePolicy,
5245        ) -> Result<PreparedEnvironment, FailureReason> {
5246            assert_eq!(
5247                attempt.state(),
5248                AttemptState::Preparing,
5249                "intent must be durable before provider effect"
5250            );
5251            self.actions.lock().unwrap().push("prepare");
5252            let identity = Self::identity(attempt, policy.host_id);
5253            self.resources
5254                .lock()
5255                .unwrap()
5256                .insert(attempt.id, (identity.clone(), EnvironmentState::Starting));
5257            if self.prepare_failure.swap(false, Ordering::SeqCst) {
5258                return Err(FailureReason::Other("provider-secret-needle".into()));
5259            }
5260            Ok(PreparedEnvironment::isolated(attempt.id, identity))
5261        }
5262
5263        fn start(
5264            &self,
5265            prepared: PreparedEnvironment,
5266            attempt: &RunnerAttempt,
5267            handoff: OneTimeJitHandoff<'_>,
5268        ) -> Result<EnvironmentIdentity, ProcessStartFailure> {
5269            assert_eq!(prepared.attempt, attempt.id);
5270            assert_eq!(attempt.state(), AttemptState::JitReceived);
5271            assert_eq!(handoff.consume().expose(), JIT);
5272            self.handoffs.fetch_add(1, Ordering::SeqCst);
5273            self.actions.lock().unwrap().push("start");
5274            let mut resources = self.resources.lock().unwrap();
5275            let (identity, state) = resources.get_mut(&attempt.id).expect("prepared resource");
5276            *state = EnvironmentState::Running;
5277            Ok(identity.clone())
5278        }
5279
5280        fn inspect(&self, attempt: &RunnerAttempt) -> Result<EnvironmentState, FailureReason> {
5281            self.actions.lock().unwrap().push("inspect");
5282            Ok(self
5283                .resources
5284                .lock()
5285                .unwrap()
5286                .get(&attempt.id)
5287                .map_or(EnvironmentState::Missing, |(_, state)| *state))
5288        }
5289
5290        fn recover(&self, attempt: &RunnerAttempt) -> Result<EnvironmentState, FailureReason> {
5291            if self.recovery_unavailable.load(Ordering::SeqCst) {
5292                return Err(FailureReason::Other(
5293                    "provider discovery unavailable".into(),
5294                ));
5295            }
5296            self.inspect(attempt)
5297        }
5298
5299        fn owns(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
5300            Ok(self
5301                .resources
5302                .lock()
5303                .unwrap()
5304                .get(&attempt.id)
5305                .is_some_and(|(identity, _)| {
5306                    identity_matches_intent(attempt_host(identity), attempt, identity)
5307                }))
5308        }
5309
5310        fn stop(&self, attempt: &RunnerAttempt) -> Result<ProviderStop, FailureReason> {
5311            assert!(self.owns(attempt)?);
5312            self.actions.lock().unwrap().push("stop");
5313            if let Some((_, state)) = self.resources.lock().unwrap().get_mut(&attempt.id) {
5314                *state = EnvironmentState::Exited;
5315            }
5316            Ok(ProviderStop::Stopped)
5317        }
5318
5319        fn destroy(&self, attempt: &RunnerAttempt) -> Result<ProviderDestroy, FailureReason> {
5320            assert!(self.owns(attempt)?);
5321            self.actions.lock().unwrap().push("destroy");
5322            if self.destroy_deferred.load(Ordering::SeqCst) {
5323                return Ok(ProviderDestroy::Deferred("provider busy"));
5324            }
5325            self.resources.lock().unwrap().remove(&attempt.id);
5326            Ok(ProviderDestroy::Destroyed)
5327        }
5328
5329        fn enumerate_owned(&self, host: HostId) -> Vec<EnvironmentIdentity> {
5330            let mut owned = self
5331                .resources
5332                .lock()
5333                .unwrap()
5334                .values()
5335                .filter(|(identity, _)| attempt_host(identity) == host)
5336                .map(|(identity, _)| identity.clone())
5337                .collect::<Vec<_>>();
5338            owned.extend(
5339                self.extra_resources
5340                    .lock()
5341                    .unwrap()
5342                    .iter()
5343                    .filter(|identity| attempt_host(identity) == host)
5344                    .cloned(),
5345            );
5346            owned
5347        }
5348
5349        fn diagnostics(&self, _attempt: &RunnerAttempt) -> Vec<ProviderDiagnostic> {
5350            vec![ProviderDiagnostic::CleanupDeferred; 40]
5351        }
5352
5353        fn spawn(
5354            &self,
5355            _attempt: &RunnerAttempt,
5356            _config: &EncodedJitConfig,
5357        ) -> Result<u32, ProcessStartFailure> {
5358            self.native_calls.fetch_add(1, Ordering::SeqCst);
5359            panic!("isolated provider must never spawn natively")
5360        }
5361        fn is_alive(&self, _attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
5362            panic!("native fallback")
5363        }
5364        fn recovered_pid(&self, _attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason> {
5365            panic!("native fallback")
5366        }
5367        fn completed_successfully(&self, _attempt: &RunnerAttempt) -> bool {
5368            self.completed_successfully.load(Ordering::SeqCst)
5369        }
5370        fn record_terminate_intent(&self, _attempt: &RunnerAttempt) -> Result<(), FailureReason> {
5371            panic!("native fallback")
5372        }
5373        fn has_terminate_intent(&self, _attempt: &RunnerAttempt) -> bool {
5374            false
5375        }
5376        fn terminate(&self, _attempt: &RunnerAttempt) -> Result<(), FailureReason> {
5377            panic!("native fallback")
5378        }
5379    }
5380
5381    fn attempt_host(identity: &EnvironmentIdentity) -> HostId {
5382        let EnvironmentIdentity::Isolated { host, .. } = identity else {
5383            panic!("isolated identity")
5384        };
5385        *host
5386    }
5387
5388    #[derive(Debug, Default)]
5389    struct FakeDemand {
5390        answers: Mutex<VecDeque<bool>>,
5391    }
5392
5393    impl FakeDemand {
5394        fn answering(answers: impl IntoIterator<Item = bool>) -> Self {
5395            Self {
5396                answers: Mutex::new(answers.into_iter().collect()),
5397            }
5398        }
5399    }
5400
5401    #[async_trait]
5402    impl DemandPersistence for FakeDemand {
5403        async fn persists(&self, _policy: PolicyId) -> bool {
5404            self.answers.lock().unwrap().pop_front().unwrap_or(true)
5405        }
5406    }
5407
5408    #[derive(Debug, Default)]
5409    struct FakeDelay(Mutex<Vec<Duration>>);
5410
5411    #[async_trait]
5412    impl RetryDelay for FakeDelay {
5413        async fn wait(&self, duration: Duration) {
5414            self.0.lock().unwrap().push(duration);
5415        }
5416    }
5417
5418    struct Harness {
5419        _root: tempfile::TempDir,
5420        app_paths: runner_manager_platform::paths::AppPaths,
5421        launcher: LifecycleLauncher,
5422        demand: Arc<dyn DemandPersistence>,
5423        store: Arc<SqliteStore>,
5424        github: Arc<FakeGithubLifecycle>,
5425        packages: Arc<FakePackages>,
5426        processes: Arc<FakeProcesses>,
5427        clock: Arc<FakeClock>,
5428        events: Arc<AttemptEventLog>,
5429        reconcile_events: Arc<crate::reconcile::EventLog>,
5430        delay: Arc<FakeDelay>,
5431        host: runner_manager_domain::model::Host,
5432        policy: ScalePolicy,
5433        allocation_lock: InProcessAllocationLock,
5434        /// The repository persistent root, once one is configured.
5435        workspace_root: Option<LocalAbsolutePath>,
5436    }
5437
5438    impl Harness {
5439        fn new(github: FakeGithubLifecycle, demand: Arc<dyn DemandPersistence>) -> Self {
5440            let root = tempfile::tempdir().unwrap();
5441            let paths = runner_manager_platform::paths::AppPaths::rooted_at(root.path());
5442            paths.create_all().unwrap();
5443            let policy = fixtures::policy()
5444                .repository("octo/repo")
5445                .autoscale("home", 2)
5446                .active()
5447                .build();
5448            // A unit harness must never inspect or write the workstation's
5449            // production runner root. On Windows that is C:\rman, which is
5450            // intentionally inaccessible to an ordinary account after a boot
5451            // service is installed. Using it made the same suite pass on a
5452            // clean CI image and fail on a correctly secured developer host.
5453            let host_root = root.path().join("host-root");
5454            fs::create_dir_all(&host_root).unwrap();
5455            let mut host = fixtures::host().build();
5456            host.runner_root_override = Some(
5457                LocalAbsolutePath::new(host_root.to_str().expect("a UTF-8 temporary path"))
5458                    .expect("a local absolute host root"),
5459            );
5460            let store = Arc::new(SqliteStore::open_in_memory().unwrap());
5461            store.put_host(&host).unwrap();
5462            let github = Arc::new(github);
5463            let packages = Arc::new(FakePackages::default());
5464            let processes = Arc::new(FakeProcesses::default());
5465            let clock = Arc::new(FakeClock::default());
5466            let events = Arc::new(AttemptEventLog::default());
5467            let reconcile_events = Arc::new(crate::reconcile::EventLog::new());
5468            let delay = Arc::new(FakeDelay::default());
5469            let ports = LifecyclePorts {
5470                store: Arc::clone(&store) as Arc<dyn Store>,
5471                github: Arc::clone(&github) as Arc<dyn LifecycleGithub>,
5472                packages: Arc::clone(&packages) as Arc<dyn RuntimePackages>,
5473                processes: Arc::clone(&processes) as Arc<dyn ProcessSupervisor>,
5474                clock: Arc::clone(&clock) as Arc<dyn Clock>,
5475                demand: Arc::clone(&demand),
5476                delay: Arc::clone(&delay) as Arc<dyn RetryDelay>,
5477                events: Arc::clone(&events) as Arc<dyn AttemptEventSink>,
5478                reconcile_events: Arc::clone(&reconcile_events) as Arc<dyn EventSink>,
5479            };
5480            let launcher = Self::launcher_over(policy.host_id, &paths, ports);
5481            Self {
5482                _root: root,
5483                app_paths: paths,
5484                launcher,
5485                demand,
5486                store,
5487                github,
5488                packages,
5489                processes,
5490                clock,
5491                events,
5492                reconcile_events,
5493                delay,
5494                host,
5495                policy,
5496                allocation_lock: InProcessAllocationLock::new(),
5497                workspace_root: None,
5498            }
5499        }
5500
5501        /// Put disposable attempts under a host root of this harness's own.
5502        ///
5503        /// Without it the launcher resolves the *platform* default, which on
5504        /// Windows is `%SystemDrive%\rman` — a real directory on the machine
5505        /// running the suite. Every test added by `c2` places its files inside
5506        /// its own temporary directory instead.
5507        fn with_host_runner_root(mut self) -> Self {
5508            let host_root = self.host_root();
5509            fs::create_dir_all(&host_root).unwrap();
5510            self.host.runner_root_override = Some(
5511                LocalAbsolutePath::new(host_root.to_str().expect("a UTF-8 temporary path"))
5512                    .expect("a local absolute host root"),
5513            );
5514            self.store.put_host(&self.host).unwrap();
5515            self
5516        }
5517
5518        /// Opt this harness's repository into a persistent workspace (D4).
5519        fn with_persistent_workspace(mut self, capacity: u16) -> Self {
5520            self = self.with_host_runner_root();
5521            let root = self._root.path().join("persist");
5522            let root = LocalAbsolutePath::new(root.to_str().expect("a UTF-8 temporary path"))
5523                .expect("a local absolute workspace root");
5524            self.policy = fixtures::policy()
5525                .repository("octo/repo")
5526                .autoscale("home", capacity)
5527                .active()
5528                .build();
5529            self.policy
5530                .set_workspace_policy(
5531                    WorkspacePolicy::persistent(root.clone(), TargetScope::Repository)
5532                        .expect("a repository may be persistent"),
5533                )
5534                .expect("a repository may be persistent");
5535            self.workspace_root = Some(root);
5536            // The journal's copy, so that cleanup's cross-check against a
5537            // surviving policy is exercised rather than skipped.
5538            self.store.insert_policy(&self.policy).unwrap();
5539            self
5540        }
5541
5542        fn workspace_root(&self) -> &LocalAbsolutePath {
5543            self.workspace_root
5544                .as_ref()
5545                .expect("this harness configured a persistent workspace")
5546        }
5547
5548        fn slot_path(&self, slot: u16) -> PathBuf {
5549            self.workspace_root().as_path().join(format!("s{slot}"))
5550        }
5551
5552        fn host_root(&self) -> PathBuf {
5553            self._root.path().join("host-root")
5554        }
5555
5556        fn attempt(&self, id: AttemptId) -> RunnerAttempt {
5557            self.store
5558                .attempt(id)
5559                .unwrap()
5560                .expect("the attempt is journalled")
5561        }
5562
5563        /// Conclude an attempt and run the real cleanup over it.
5564        fn conclude(&self, id: AttemptId) -> RunnerAttempt {
5565            let mut attempt = self.attempt(id);
5566            attempt
5567                .conclude(
5568                    AttemptOutcome::failed(FailureReason::ProcessExitedUnexpectedly),
5569                    self.clock.now(),
5570                )
5571                .unwrap();
5572            self.store.record_attempt(&attempt).unwrap();
5573            attempt
5574        }
5575
5576        async fn cleanup_retaining_work(&self, id: AttemptId) {
5577            self.conclude(id);
5578            self.launcher
5579                .clean(id)
5580                .await
5581                .expect("the slot is scrubbed and the lease released");
5582        }
5583
5584        /// The launcher configuration every launcher in this harness shares, so
5585        /// that the one a restart mints cannot drift from the original.
5586        fn launcher_over(
5587            host: HostId,
5588            paths: &runner_manager_platform::paths::AppPaths,
5589            ports: LifecyclePorts,
5590        ) -> LifecycleLauncher {
5591            LifecycleLauncher::new(
5592                host,
5593                paths.clone(),
5594                paths.logs_dir(),
5595                1,
5596                RecoveryTimeouts::new(
5597                    Elapsed::seconds(10),
5598                    Elapsed::seconds(10),
5599                    Elapsed::seconds(10),
5600                ),
5601                RetryPolicy::bounded(3, Duration::from_millis(10), Duration::from_millis(25)),
5602                ports,
5603            )
5604        }
5605
5606        /// The same journal, the same directories, a launcher that remembers
5607        /// nothing — which is what a daemon restart is.
5608        fn restart(&self) -> LifecycleLauncher {
5609            Self::launcher_over(
5610                self.policy.host_id,
5611                &self.app_paths,
5612                LifecyclePorts {
5613                    store: Arc::clone(&self.store) as Arc<dyn Store>,
5614                    github: Arc::clone(&self.github) as Arc<dyn LifecycleGithub>,
5615                    packages: Arc::clone(&self.packages) as Arc<dyn RuntimePackages>,
5616                    processes: Arc::clone(&self.processes) as Arc<dyn ProcessSupervisor>,
5617                    clock: Arc::clone(&self.clock) as Arc<dyn Clock>,
5618                    demand: Arc::clone(&self.demand),
5619                    delay: Arc::clone(&self.delay) as Arc<dyn RetryDelay>,
5620                    events: Arc::clone(&self.events) as Arc<dyn AttemptEventSink>,
5621                    reconcile_events: Arc::clone(&self.reconcile_events) as Arc<dyn EventSink>,
5622                },
5623            )
5624        }
5625
5626        async fn ready(&self) {
5627            self.launcher
5628                .recover_startup(std::slice::from_ref(&self.policy))
5629                .await
5630                .unwrap();
5631        }
5632
5633        async fn launch(&self) -> RunnerAttempt {
5634            self.launch_result().await.unwrap()
5635        }
5636
5637        async fn launch_result(&self) -> Result<RunnerAttempt, LaunchFailure> {
5638            self.launch_after_waiting(Duration::ZERO).await
5639        }
5640
5641        async fn launch_after_waiting(
5642            &self,
5643            lock_wait: Duration,
5644        ) -> Result<RunnerAttempt, LaunchFailure> {
5645            let guard = self.allocation_lock.acquire().await.unwrap();
5646            self.launcher
5647                .launch(LaunchRequest {
5648                    host: &self.host,
5649                    policy: &self.policy,
5650                    allocation_guard: &guard,
5651                    lock_wait,
5652                })
5653                .await
5654        }
5655
5656        fn only_attempt(&self) -> RunnerAttempt {
5657            self.store.attempts().unwrap().into_iter().next().unwrap()
5658        }
5659    }
5660
5661    fn isolated_harness() -> Harness {
5662        use runner_manager_domain::execution::{ExecutionPolicy, ResourceLimits};
5663        let mut harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
5664            .with_host_runner_root();
5665        let image =
5666            ImageReference::new(format!("registry.example/runner@sha256:{}", "a".repeat(64)))
5667                .expect("pinned image");
5668        harness
5669            .policy
5670            .set_execution_policy(ExecutionPolicy::Isolated {
5671                backend: Backend::Oci,
5672                image,
5673                resources: ResourceLimits {
5674                    cpu_millis: 1000,
5675                    memory_mib: 1024,
5676                    disk_mib: 4096,
5677                },
5678            })
5679            .expect("isolated policy");
5680        harness.github.watch_journal(Arc::clone(&harness.store));
5681        harness
5682    }
5683
5684    fn launcher_with_isolated(
5685        harness: &Harness,
5686        provider: Arc<FakeIsolatedProvider>,
5687    ) -> LifecycleLauncher {
5688        Harness::launcher_over(
5689            harness.policy.host_id,
5690            &harness.app_paths,
5691            LifecyclePorts {
5692                store: Arc::clone(&harness.store) as Arc<dyn Store>,
5693                github: Arc::clone(&harness.github) as Arc<dyn LifecycleGithub>,
5694                packages: Arc::clone(&harness.packages) as Arc<dyn RuntimePackages>,
5695                processes: provider as Arc<dyn ExecutionProvider>,
5696                clock: Arc::clone(&harness.clock) as Arc<dyn Clock>,
5697                demand: Arc::clone(&harness.demand),
5698                delay: Arc::clone(&harness.delay) as Arc<dyn RetryDelay>,
5699                events: Arc::clone(&harness.events) as Arc<dyn AttemptEventSink>,
5700                reconcile_events: Arc::clone(&harness.reconcile_events) as Arc<dyn EventSink>,
5701            },
5702        )
5703    }
5704
5705    #[tokio::test]
5706    async fn isolated_policy_and_journal_never_enter_native_provider_or_jit() {
5707        use runner_manager_domain::execution::{
5708            AttemptExecution, Backend, ExecutionPolicy, ImageReference, ResourceLimits,
5709        };
5710
5711        let mut harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
5712            .with_host_runner_root();
5713        let image =
5714            ImageReference::new(format!("registry.example/runner@sha256:{}", "a".repeat(64)))
5715                .expect("pinned image");
5716        harness
5717            .policy
5718            .set_execution_policy(ExecutionPolicy::Isolated {
5719                backend: Backend::Oci,
5720                image: image.clone(),
5721                resources: ResourceLimits {
5722                    cpu_millis: 1000,
5723                    memory_mib: 1024,
5724                    disk_mib: 4096,
5725                },
5726            })
5727            .expect("valid isolated profile");
5728
5729        harness.ready().await;
5730        assert_eq!(
5731            harness.processes.probe(&harness.policy),
5732            ProviderCapability::Unsupported
5733        );
5734        let failure = harness
5735            .launch_result()
5736            .await
5737            .expect_err("provider is unavailable");
5738        assert_eq!(
5739            failure.reason,
5740            FailureReason::IsolationProvider(IsolationProviderFailure::Unsupported)
5741        );
5742
5743        let runtime = harness.host_root().join("isolated-owned-environment");
5744        fs::create_dir_all(&runtime).expect("provider resource fixture");
5745        fs::write(runtime.join("owned-marker"), "non-secret").expect("fixture marker");
5746        let mut attempt = RunnerAttempt::allocate(
5747            AttemptId::from_u128(0x123),
5748            harness.policy.id,
5749            runtime.clone(),
5750            harness.clock.now(),
5751        );
5752        attempt
5753            .allocate_execution(AttemptExecution::Isolated {
5754                provider_kind: Backend::Oci,
5755                environment_id: None,
5756                resolved_image: image,
5757                generation: "generation-123".into(),
5758            })
5759            .expect("isolated intent");
5760        attempt
5761            .prepared_environment("environment-123".into())
5762            .expect("prepared identity");
5763        harness
5764            .store
5765            .record_attempt(&attempt)
5766            .expect("durable isolated journal");
5767
5768        harness
5769            .launcher
5770            .recover_startup(std::slice::from_ref(&harness.policy))
5771            .await
5772            .expect("unsupported isolated cleanup is profile-local");
5773        let failure = harness
5774            .launch_result()
5775            .await
5776            .expect_err("provider unavailable for this policy");
5777        assert_eq!(
5778            failure.reason,
5779            FailureReason::IsolationProvider(IsolationProviderFailure::Unsupported)
5780        );
5781        harness
5782            .launcher
5783            .supervise(&harness.policy)
5784            .await
5785            .expect("unavailable attempt is deferred");
5786        assert!(harness.launcher.clean(attempt.id).await.is_err());
5787        assert_eq!(
5788            harness.store.attempt(attempt.id).unwrap().unwrap().state(),
5789            AttemptState::CleanupDeferred
5790        );
5791        assert!(matches!(
5792            harness.launcher.recover_startup(&[]).await,
5793            Err(LifecycleError::RecoveryIncomplete)
5794        ));
5795        assert!(
5796            runtime.join("owned-marker").is_file(),
5797            "native cleanup touched the isolated environment"
5798        );
5799        assert!(
5800            harness.processes.actions.lock().unwrap().is_empty(),
5801            "native recovery inspected an isolated identity"
5802        );
5803        assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 0);
5804        assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
5805        assert_eq!(harness.packages.materializations.load(Ordering::SeqCst), 0);
5806    }
5807
5808    #[tokio::test]
5809    async fn isolated_prepare_is_durable_before_jit_and_start_uses_one_handoff() {
5810        let harness = isolated_harness();
5811        let provider = Arc::new(FakeIsolatedProvider::default());
5812        let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
5813        launcher
5814            .recover_startup(std::slice::from_ref(&harness.policy))
5815            .await
5816            .expect("ready");
5817        let guard = harness.allocation_lock.acquire().await.unwrap();
5818        let attempt = launcher
5819            .launch(LaunchRequest {
5820                host: &harness.host,
5821                policy: &harness.policy,
5822                allocation_guard: &guard,
5823                lock_wait: Duration::ZERO,
5824            })
5825            .await
5826            .expect("isolated launch");
5827        assert_eq!(attempt.state(), AttemptState::Starting);
5828        assert_eq!(provider.resource_count(), 1);
5829        assert_eq!(provider.handoffs.load(Ordering::SeqCst), 1);
5830        assert_eq!(provider.native_calls.load(Ordering::SeqCst), 0);
5831        assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 1);
5832        let fact = harness
5833            .github
5834            .registration_facts()
5835            .pop()
5836            .expect("JIT registration");
5837        assert_eq!(
5838            fact.journal_states,
5839            vec![AttemptState::Prepared],
5840            "provider readiness preceded GitHub JIT"
5841        );
5842        assert_eq!(
5843            harness
5844                .events
5845                .events()
5846                .into_iter()
5847                .filter_map(|event| match event {
5848                    AttemptEvent::State { state, .. } => Some(state),
5849                    _ => None,
5850                })
5851                .collect::<Vec<_>>(),
5852            vec![
5853                AttemptState::Allocated,
5854                AttemptState::Preparing,
5855                AttemptState::Prepared,
5856                AttemptState::JitReceived,
5857                AttemptState::Starting
5858            ]
5859        );
5860        let restarted = launcher_with_isolated(&harness, Arc::clone(&provider));
5861        restarted
5862            .recover_startup(std::slice::from_ref(&harness.policy))
5863            .await
5864            .expect("adopt live sandbox");
5865        assert_eq!(provider.resource_count(), 1);
5866        assert_eq!(
5867            harness.github.registrations.load(Ordering::SeqCst),
5868            1,
5869            "restart cannot issue duplicate JIT"
5870        );
5871        harness
5872            .github
5873            .observe(GithubRunnerObservation::Registered { busy: true });
5874        restarted
5875            .supervise(&harness.policy)
5876            .await
5877            .expect("job observed");
5878        assert_eq!(
5879            harness.store.attempt(attempt.id).unwrap().unwrap().state(),
5880            AttemptState::Busy
5881        );
5882        provider
5883            .resources
5884            .lock()
5885            .unwrap()
5886            .get_mut(&attempt.id)
5887            .unwrap()
5888            .1 = EnvironmentState::Exited;
5889        harness
5890            .github
5891            .observe(GithubRunnerObservation::NotRegistered);
5892        restarted
5893            .supervise(&harness.policy)
5894            .await
5895            .expect("terminal cleanup");
5896        assert_eq!(provider.resource_count(), 0);
5897        assert_eq!(
5898            harness.store.attempt(attempt.id).unwrap().unwrap().state(),
5899            AttemptState::Cleaned
5900        );
5901    }
5902
5903    #[tokio::test]
5904    async fn attached_isolated_job_exit_is_recorded_as_completed() {
5905        let harness = isolated_harness();
5906        let provider = Arc::new(FakeIsolatedProvider::default());
5907        let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
5908        launcher
5909            .recover_startup(std::slice::from_ref(&harness.policy))
5910            .await
5911            .unwrap();
5912        let guard = harness.allocation_lock.acquire().await.unwrap();
5913        let attempt = launcher
5914            .launch(LaunchRequest {
5915                host: &harness.host,
5916                policy: &harness.policy,
5917                allocation_guard: &guard,
5918                lock_wait: Duration::ZERO,
5919            })
5920            .await
5921            .unwrap();
5922        harness
5923            .github
5924            .observe(GithubRunnerObservation::Registered { busy: true });
5925        launcher.supervise(&harness.policy).await.unwrap();
5926        provider
5927            .resources
5928            .lock()
5929            .unwrap()
5930            .get_mut(&attempt.id)
5931            .unwrap()
5932            .1 = EnvironmentState::Exited;
5933        provider
5934            .completed_successfully
5935            .store(true, Ordering::SeqCst);
5936        harness
5937            .github
5938            .observe(GithubRunnerObservation::NotRegistered);
5939        launcher.supervise(&harness.policy).await.unwrap();
5940        let cleaned = harness.store.attempt(attempt.id).unwrap().unwrap();
5941        assert_eq!(cleaned.outcome(), Some(&AttemptOutcome::CompletedJob));
5942        assert_eq!(cleaned.state(), AttemptState::Cleaned);
5943    }
5944
5945    #[tokio::test]
5946    async fn isolated_prepare_failure_stays_local_and_crash_gap_recovers_owned_resource() {
5947        let harness = isolated_harness();
5948        let provider = Arc::new(FakeIsolatedProvider::default());
5949        provider.prepare_failure.store(true, Ordering::SeqCst);
5950        let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
5951        launcher
5952            .recover_startup(std::slice::from_ref(&harness.policy))
5953            .await
5954            .expect("ready");
5955        let guard = harness.allocation_lock.acquire().await.unwrap();
5956        let failure = launcher
5957            .launch(LaunchRequest {
5958                host: &harness.host,
5959                policy: &harness.policy,
5960                allocation_guard: &guard,
5961                lock_wait: Duration::ZERO,
5962            })
5963            .await
5964            .expect_err("prepare failed after creating resource");
5965        assert_eq!(
5966            failure.reason,
5967            FailureReason::IsolationProvider(IsolationProviderFailure::RuntimeOperationFailed)
5968        );
5969        assert_eq!(provider.resource_count(), 1);
5970        assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
5971        assert!(
5972            !harness
5973                .store
5974                .dump_text()
5975                .unwrap()
5976                .contains("provider-secret-needle")
5977        );
5978        let id = harness.store.attempts().unwrap()[0].id;
5979        assert_eq!(
5980            harness.store.attempt(id).unwrap().unwrap().outcome(),
5981            Some(&AttemptOutcome::failed(FailureReason::IsolationProvider(
5982                IsolationProviderFailure::RuntimeOperationFailed
5983            )))
5984        );
5985        let restarted = launcher_with_isolated(&harness, Arc::clone(&provider));
5986        restarted
5987            .recover_startup(std::slice::from_ref(&harness.policy))
5988            .await
5989            .expect("profile-local recovery");
5990        assert_eq!(
5991            provider.resource_count(),
5992            0,
5993            "the exact owned resource was destroyed once"
5994        );
5995        assert_eq!(
5996            harness.store.attempt(id).unwrap().unwrap().state(),
5997            AttemptState::Cleaned
5998        );
5999        let diagnostic =
6000            fs::read_to_string(restarted.diagnostics_root.join(format!("{id}.log"))).unwrap();
6001        assert!(diagnostic.contains("reason=isolation_runtime_operation_failed"));
6002        assert!(!diagnostic.contains("provider-secret-needle"));
6003        assert_eq!(
6004            provider
6005                .actions
6006                .lock()
6007                .unwrap()
6008                .iter()
6009                .filter(|action| **action == "destroy")
6010                .count(),
6011            1
6012        );
6013        assert_eq!(provider.native_calls.load(Ordering::SeqCst), 0);
6014    }
6015
6016    #[test]
6017    fn isolation_capability_and_adapter_failures_keep_closed_categories() {
6018        let cases = [
6019            (
6020                ProviderCapability::NotInstalled,
6021                IsolationProviderFailure::NotInstalled,
6022            ),
6023            (
6024                ProviderCapability::PermissionDenied,
6025                IsolationProviderFailure::PermissionDenied,
6026            ),
6027            (
6028                ProviderCapability::ImageUnavailableOrIncompatible,
6029                IsolationProviderFailure::ImageUnavailableOrIncompatible,
6030            ),
6031            (
6032                ProviderCapability::DiskQuotaUnavailable,
6033                IsolationProviderFailure::DiskQuotaUnavailable,
6034            ),
6035            (
6036                ProviderCapability::Degraded,
6037                IsolationProviderFailure::Degraded,
6038            ),
6039        ];
6040        for (capability, category) in cases {
6041            assert_eq!(
6042                capability.refusal(),
6043                FailureReason::IsolationProvider(category)
6044            );
6045        }
6046        let raw = FailureReason::Other("ghp_secret_from_provider".into());
6047        let safe = safe_isolation_reason(raw, IsolationProviderFailure::RuntimeOperationFailed);
6048        assert_eq!(
6049            safe,
6050            FailureReason::IsolationProvider(IsolationProviderFailure::RuntimeOperationFailed)
6051        );
6052        assert!(!safe.to_string().contains("ghp_secret"));
6053        assert_eq!(
6054            safe_isolation_reason(
6055                FailureReason::IsolationProvider(IsolationProviderFailure::PermissionDenied),
6056                IsolationProviderFailure::RuntimeOperationFailed,
6057            ),
6058            FailureReason::IsolationProvider(IsolationProviderFailure::PermissionDenied)
6059        );
6060    }
6061
6062    #[tokio::test]
6063    async fn isolated_permission_and_image_failures_stop_before_jit_without_native_fallback() {
6064        for image_failure in [false, true] {
6065            let harness = isolated_harness();
6066            let provider = Arc::new(FakeIsolatedProvider::default());
6067            provider
6068                .permission_denied
6069                .store(!image_failure, Ordering::SeqCst);
6070            provider
6071                .incompatible_image
6072                .store(image_failure, Ordering::SeqCst);
6073            let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
6074            launcher
6075                .recover_startup(std::slice::from_ref(&harness.policy))
6076                .await
6077                .unwrap();
6078            let guard = harness.allocation_lock.acquire().await.unwrap();
6079            let error = launcher
6080                .launch(LaunchRequest {
6081                    host: &harness.host,
6082                    policy: &harness.policy,
6083                    allocation_guard: &guard,
6084                    lock_wait: Duration::ZERO,
6085                })
6086                .await
6087                .expect_err("pre-JIT refusal");
6088            let expected = if image_failure {
6089                "resolved image is incompatible"
6090            } else {
6091                "permission denied"
6092            };
6093            assert!(error.reason.to_string().contains(expected));
6094            assert!(
6095                harness.store.attempts().unwrap().is_empty(),
6096                "no allocation was journalled"
6097            );
6098            assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
6099            assert_eq!(provider.handoffs.load(Ordering::SeqCst), 0);
6100            assert_eq!(provider.native_calls.load(Ordering::SeqCst), 0);
6101        }
6102    }
6103
6104    #[tokio::test]
6105    async fn unsupported_host_stops_before_jit_without_allocation_or_native_fallback() {
6106        let harness = isolated_harness();
6107        let provider = Arc::new(FakeIsolatedProvider::default());
6108        provider.unsupported_host.store(true, Ordering::SeqCst);
6109        let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
6110        launcher
6111            .recover_startup(std::slice::from_ref(&harness.policy))
6112            .await
6113            .unwrap();
6114        let guard = harness.allocation_lock.acquire().await.unwrap();
6115        let error = launcher
6116            .launch(LaunchRequest {
6117                host: &harness.host,
6118                policy: &harness.policy,
6119                allocation_guard: &guard,
6120                lock_wait: Duration::ZERO,
6121            })
6122            .await
6123            .expect_err("unsupported host must be refused before JIT");
6124
6125        assert_eq!(
6126            error.reason,
6127            FailureReason::IsolationProvider(IsolationProviderFailure::Unsupported)
6128        );
6129        assert!(harness.store.attempts().unwrap().is_empty());
6130        assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
6131        assert_eq!(provider.handoffs.load(Ordering::SeqCst), 0);
6132        assert_eq!(provider.native_calls.load(Ordering::SeqCst), 0);
6133        assert!(provider.actions.lock().unwrap().is_empty());
6134    }
6135
6136    #[tokio::test]
6137    async fn no_journalled_environment_id_waits_for_provider_absence_proof() {
6138        let harness = isolated_harness();
6139        let provider = Arc::new(FakeIsolatedProvider::default());
6140        let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
6141        let runtime = harness.host_root().join("prepare-without-identity");
6142        fs::create_dir_all(&runtime).unwrap();
6143        let mut attempt = RunnerAttempt::allocate(
6144            AttemptId::from_u128(0x9191),
6145            harness.policy.id,
6146            runtime,
6147            harness.clock.now(),
6148        );
6149        let runner_manager_domain::execution::ExecutionPolicy::Isolated { image, .. } =
6150            harness.policy.execution_policy()
6151        else {
6152            unreachable!()
6153        };
6154        attempt
6155            .allocate_execution(AttemptExecution::Isolated {
6156                provider_kind: Backend::Oci,
6157                environment_id: None,
6158                resolved_image: image.clone(),
6159                generation: "prepare-generation".into(),
6160            })
6161            .unwrap();
6162        attempt.begin_prepare(harness.clock.now()).unwrap();
6163        attempt
6164            .conclude(
6165                AttemptOutcome::failed(FailureReason::Other("prepare interrupted".into())),
6166                harness.clock.now(),
6167            )
6168            .unwrap();
6169        harness.store.record_attempt(&attempt).unwrap();
6170
6171        provider.recovery_unavailable.store(true, Ordering::SeqCst);
6172        launcher
6173            .clean(attempt.id)
6174            .await
6175            .expect_err("absence unproven");
6176        let deferred = harness.store.attempt(attempt.id).unwrap().unwrap();
6177        assert_eq!(deferred.state(), AttemptState::CleanupDeferred);
6178        assert!(deferred.counts_against_capacity());
6179
6180        provider.recovery_unavailable.store(false, Ordering::SeqCst);
6181        launcher.clean(attempt.id).await.expect("absence proven");
6182        assert_eq!(
6183            harness.store.attempt(attempt.id).unwrap().unwrap().state(),
6184            AttemptState::Cleaned
6185        );
6186    }
6187
6188    #[tokio::test]
6189    async fn isolated_terminal_capacity_waits_for_confirmed_absence_and_generation_matches() {
6190        let harness = isolated_harness();
6191        let provider = Arc::new(FakeIsolatedProvider::default());
6192        let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
6193        launcher
6194            .recover_startup(std::slice::from_ref(&harness.policy))
6195            .await
6196            .expect("ready");
6197        let guard = harness.allocation_lock.acquire().await.unwrap();
6198        let mut attempt = launcher
6199            .launch(LaunchRequest {
6200                host: &harness.host,
6201                policy: &harness.policy,
6202                allocation_guard: &guard,
6203                lock_wait: Duration::ZERO,
6204            })
6205            .await
6206            .expect("launch");
6207        attempt
6208            .conclude(
6209                AttemptOutcome::failed(FailureReason::ProcessStartFailed),
6210                harness.clock.now(),
6211            )
6212            .unwrap();
6213        harness.store.record_attempt(&attempt).unwrap();
6214        provider.destroy_deferred.store(true, Ordering::SeqCst);
6215        launcher
6216            .clean(attempt.id)
6217            .await
6218            .expect_err("provider deferred destroy");
6219        let deferred = harness.store.attempt(attempt.id).unwrap().unwrap();
6220        assert_eq!(deferred.state(), AttemptState::CleanupDeferred);
6221        assert!(deferred.counts_against_capacity());
6222        assert_eq!(provider.resource_count(), 1);
6223        provider.destroy_deferred.store(false, Ordering::SeqCst);
6224        let restarted = launcher_with_isolated(&harness, Arc::clone(&provider));
6225        restarted
6226            .recover_startup(std::slice::from_ref(&harness.policy))
6227            .await
6228            .expect("retry cleanup");
6229        assert_eq!(provider.resource_count(), 0);
6230        assert!(
6231            !harness
6232                .store
6233                .attempt(attempt.id)
6234                .unwrap()
6235                .unwrap()
6236                .counts_against_capacity()
6237        );
6238
6239        // A reused runtime name with a different generation is not ours to delete.
6240        let second_id = AttemptId::from_u128(0x9999);
6241        let second_runtime = harness.host_root().join("generation-mismatch");
6242        fs::create_dir_all(&second_runtime).unwrap();
6243        let mut terminal = RunnerAttempt::allocate(
6244            second_id,
6245            harness.policy.id,
6246            second_runtime,
6247            harness.clock.now(),
6248        );
6249        let runner_manager_domain::execution::ExecutionPolicy::Isolated { image, .. } =
6250            harness.policy.execution_policy()
6251        else {
6252            unreachable!()
6253        };
6254        terminal
6255            .allocate_execution(AttemptExecution::Isolated {
6256                provider_kind: Backend::Oci,
6257                environment_id: None,
6258                resolved_image: image.clone(),
6259                generation: "expected-generation".into(),
6260            })
6261            .unwrap();
6262        let mut stranger = FakeIsolatedProvider::identity(&terminal, harness.policy.host_id);
6263        let EnvironmentIdentity::Isolated { environment_id, .. } = &stranger else {
6264            unreachable!()
6265        };
6266        terminal.begin_prepare(harness.clock.now()).unwrap();
6267        terminal
6268            .prepared_environment(environment_id.clone())
6269            .unwrap();
6270        terminal.mark_prepared(harness.clock.now()).unwrap();
6271        terminal.jit_received(harness.clock.now()).unwrap();
6272        terminal.started_isolated(harness.clock.now()).unwrap();
6273        terminal
6274            .conclude(
6275                AttemptOutcome::failed(FailureReason::ProcessStartFailed),
6276                harness.clock.now(),
6277            )
6278            .unwrap();
6279        harness.store.record_attempt(&terminal).unwrap();
6280        let EnvironmentIdentity::Isolated { generation, .. } = &mut stranger else {
6281            unreachable!()
6282        };
6283        *generation = "another-generation".into();
6284        provider
6285            .resources
6286            .lock()
6287            .unwrap()
6288            .insert(second_id, (stranger, EnvironmentState::Running));
6289        restarted
6290            .clean(terminal.id)
6291            .await
6292            .expect_err("ownership mismatch quarantined");
6293        assert_eq!(provider.resource_count(), 1);
6294        assert_eq!(
6295            harness.store.attempt(terminal.id).unwrap().unwrap().state(),
6296            AttemptState::CleanupDeferred
6297        );
6298        assert_eq!(provider.native_calls.load(Ordering::SeqCst), 0);
6299    }
6300
6301    #[tokio::test]
6302    async fn isolated_restart_at_each_transition_adopts_or_destroys_one_resource() {
6303        for stage in [
6304            AttemptState::Allocated,
6305            AttemptState::Preparing,
6306            AttemptState::Prepared,
6307            AttemptState::JitReceived,
6308            AttemptState::Starting,
6309            AttemptState::Idle,
6310            AttemptState::Busy,
6311            AttemptState::Finished,
6312            AttemptState::Failed,
6313            AttemptState::Orphaned,
6314            AttemptState::Destroying,
6315            AttemptState::CleanupDeferred,
6316        ] {
6317            let harness = isolated_harness();
6318            let provider = Arc::new(FakeIsolatedProvider::default());
6319            let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
6320            let id = AttemptId::from_u128(0x1000 + stage as u128);
6321            let runtime = harness.host_root().join(format!("phase-{stage}"));
6322            fs::create_dir_all(&runtime).unwrap();
6323            let mut attempt =
6324                RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
6325            let runner_manager_domain::execution::ExecutionPolicy::Isolated { image, .. } =
6326                harness.policy.execution_policy()
6327            else {
6328                unreachable!()
6329            };
6330            attempt
6331                .allocate_execution(AttemptExecution::Isolated {
6332                    provider_kind: Backend::Oci,
6333                    environment_id: None,
6334                    resolved_image: image.clone(),
6335                    generation: format!("generation-{stage}"),
6336                })
6337                .unwrap();
6338            if stage != AttemptState::Allocated {
6339                attempt.begin_prepare(harness.clock.now()).unwrap();
6340            }
6341            let identity = FakeIsolatedProvider::identity(&attempt, harness.policy.host_id);
6342            if stage != AttemptState::Allocated {
6343                let resource_state = if matches!(
6344                    stage,
6345                    AttemptState::Finished
6346                        | AttemptState::Failed
6347                        | AttemptState::Orphaned
6348                        | AttemptState::Destroying
6349                        | AttemptState::CleanupDeferred
6350                ) {
6351                    EnvironmentState::Exited
6352                } else {
6353                    EnvironmentState::Running
6354                };
6355                provider
6356                    .resources
6357                    .lock()
6358                    .unwrap()
6359                    .insert(id, (identity.clone(), resource_state));
6360            }
6361            if !matches!(stage, AttemptState::Allocated | AttemptState::Preparing) {
6362                let EnvironmentIdentity::Isolated { environment_id, .. } = &identity else {
6363                    unreachable!()
6364                };
6365                attempt
6366                    .prepared_environment(environment_id.clone())
6367                    .unwrap();
6368                attempt.mark_prepared(harness.clock.now()).unwrap();
6369            }
6370            if matches!(
6371                stage,
6372                AttemptState::JitReceived
6373                    | AttemptState::Starting
6374                    | AttemptState::Idle
6375                    | AttemptState::Busy
6376                    | AttemptState::Finished
6377                    | AttemptState::Failed
6378                    | AttemptState::Orphaned
6379                    | AttemptState::Destroying
6380                    | AttemptState::CleanupDeferred
6381            ) {
6382                attempt.jit_received(harness.clock.now()).unwrap();
6383            }
6384            if matches!(
6385                stage,
6386                AttemptState::Starting
6387                    | AttemptState::Idle
6388                    | AttemptState::Busy
6389                    | AttemptState::Finished
6390                    | AttemptState::Failed
6391                    | AttemptState::Orphaned
6392                    | AttemptState::Destroying
6393                    | AttemptState::CleanupDeferred
6394            ) {
6395                attempt.started_isolated(harness.clock.now()).unwrap();
6396            }
6397            if matches!(
6398                stage,
6399                AttemptState::Idle | AttemptState::Busy | AttemptState::Finished
6400            ) {
6401                attempt.registered_idle(73, harness.clock.now()).unwrap();
6402            }
6403            if matches!(stage, AttemptState::Busy | AttemptState::Finished) {
6404                attempt.assigned_job(73, harness.clock.now()).unwrap();
6405            }
6406            if matches!(
6407                stage,
6408                AttemptState::Finished
6409                    | AttemptState::Failed
6410                    | AttemptState::Orphaned
6411                    | AttemptState::Destroying
6412                    | AttemptState::CleanupDeferred
6413            ) {
6414                let outcome = match stage {
6415                    AttemptState::Finished => AttemptOutcome::CompletedJob,
6416                    AttemptState::Orphaned => AttemptOutcome::Orphaned,
6417                    _ => AttemptOutcome::failed(FailureReason::ProcessStartFailed),
6418                };
6419                attempt.conclude(outcome, harness.clock.now()).unwrap();
6420            }
6421            if matches!(
6422                stage,
6423                AttemptState::Destroying | AttemptState::CleanupDeferred
6424            ) {
6425                attempt.begin_destroy(harness.clock.now()).unwrap();
6426            }
6427            if stage == AttemptState::CleanupDeferred {
6428                attempt.defer_cleanup(harness.clock.now()).unwrap();
6429            }
6430            harness.store.record_attempt(&attempt).unwrap();
6431            launcher
6432                .recover_startup(std::slice::from_ref(&harness.policy))
6433                .await
6434                .unwrap_or_else(|error| panic!("restart from {stage}: {error}"));
6435            let resumed = harness.store.attempt(id).unwrap().unwrap();
6436            if matches!(
6437                stage,
6438                AttemptState::Allocated
6439                    | AttemptState::Preparing
6440                    | AttemptState::Prepared
6441                    | AttemptState::Finished
6442                    | AttemptState::Failed
6443                    | AttemptState::Orphaned
6444                    | AttemptState::Destroying
6445                    | AttemptState::CleanupDeferred
6446            ) {
6447                assert_eq!(
6448                    resumed.state(),
6449                    AttemptState::Cleaned,
6450                    "{stage} must retire after restart"
6451                );
6452            } else {
6453                assert!(resumed.counts_against_capacity(), "live {stage} is adopted");
6454                assert_eq!(provider.resource_count(), 1);
6455                provider.resources.lock().unwrap().get_mut(&id).unwrap().1 =
6456                    EnvironmentState::Exited;
6457                launcher.supervise(&harness.policy).await.unwrap();
6458                assert_eq!(
6459                    harness.store.attempt(id).unwrap().unwrap().state(),
6460                    AttemptState::Cleaned
6461                );
6462            }
6463            assert_eq!(
6464                provider.resource_count(),
6465                0,
6466                "{stage} resource cleaned exactly once"
6467            );
6468            let destroyed = provider
6469                .actions
6470                .lock()
6471                .unwrap()
6472                .iter()
6473                .filter(|action| **action == "destroy")
6474                .count();
6475            assert_eq!(
6476                destroyed,
6477                usize::from(stage != AttemptState::Allocated),
6478                "{stage} destroy count"
6479            );
6480            assert_eq!(
6481                harness.github.registrations.load(Ordering::SeqCst),
6482                0,
6483                "restart from {stage} cannot duplicate JIT"
6484            );
6485            assert_eq!(provider.native_calls.load(Ordering::SeqCst), 0);
6486        }
6487    }
6488
6489    #[tokio::test]
6490    async fn duplicate_provider_ownership_is_quarantined_until_unambiguous() {
6491        let harness = isolated_harness();
6492        let provider = Arc::new(FakeIsolatedProvider::default());
6493        let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
6494        let id = AttemptId::from_u128(0x7777);
6495        let runtime = harness.host_root().join("duplicate-provider-ownership");
6496        fs::create_dir_all(&runtime).unwrap();
6497        let mut attempt =
6498            RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
6499        let runner_manager_domain::execution::ExecutionPolicy::Isolated { image, .. } =
6500            harness.policy.execution_policy()
6501        else {
6502            unreachable!()
6503        };
6504        attempt
6505            .allocate_execution(AttemptExecution::Isolated {
6506                provider_kind: Backend::Oci,
6507                environment_id: None,
6508                resolved_image: image.clone(),
6509                generation: "duplicate-generation".into(),
6510            })
6511            .unwrap();
6512        attempt.begin_prepare(harness.clock.now()).unwrap();
6513        harness.store.record_attempt(&attempt).unwrap();
6514        let first = FakeIsolatedProvider::identity(&attempt, harness.policy.host_id);
6515        let mut second = first.clone();
6516        let EnvironmentIdentity::Isolated { environment_id, .. } = &mut second else {
6517            unreachable!()
6518        };
6519        *environment_id = "another-environment".into();
6520        provider
6521            .resources
6522            .lock()
6523            .unwrap()
6524            .insert(id, (first, EnvironmentState::Running));
6525        provider.extra_resources.lock().unwrap().push(second);
6526        launcher
6527            .recover_startup(std::slice::from_ref(&harness.policy))
6528            .await
6529            .expect("duplicate is local");
6530        let deferred = harness.store.attempt(id).unwrap().unwrap();
6531        assert_eq!(deferred.state(), AttemptState::CleanupDeferred);
6532        assert!(deferred.counts_against_capacity());
6533        assert_eq!(provider.resource_count(), 1);
6534        assert_eq!(
6535            provider
6536                .actions
6537                .lock()
6538                .unwrap()
6539                .iter()
6540                .filter(|action| **action == "destroy")
6541                .count(),
6542            0
6543        );
6544        provider.extra_resources.lock().unwrap().clear();
6545        // A tolerated cleanup failure is retried after its backoff delay.
6546        harness.clock.advance_secs(31);
6547        launcher
6548            .recover_startup(std::slice::from_ref(&harness.policy))
6549            .await
6550            .expect("single identity recovered");
6551        assert_eq!(provider.resource_count(), 0);
6552        assert_eq!(
6553            harness.store.attempt(id).unwrap().unwrap().state(),
6554            AttemptState::Cleaned
6555        );
6556    }
6557
6558    #[tokio::test]
6559    async fn provider_resource_without_live_journal_is_surfaced_but_never_deleted() {
6560        let harness = isolated_harness();
6561        let provider = Arc::new(FakeIsolatedProvider::default());
6562        let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
6563        let id = AttemptId::from_u128(0x8888);
6564        let runtime = harness.host_root().join("orphan-provider-resource");
6565        fs::create_dir_all(&runtime).unwrap();
6566        let mut attempt =
6567            RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
6568        let runner_manager_domain::execution::ExecutionPolicy::Isolated { image, .. } =
6569            harness.policy.execution_policy()
6570        else {
6571            unreachable!()
6572        };
6573        attempt
6574            .allocate_execution(AttemptExecution::Isolated {
6575                provider_kind: Backend::Oci,
6576                environment_id: None,
6577                resolved_image: image.clone(),
6578                generation: "orphan-generation".into(),
6579            })
6580            .unwrap();
6581        let identity = FakeIsolatedProvider::identity(&attempt, harness.policy.host_id);
6582        provider
6583            .resources
6584            .lock()
6585            .unwrap()
6586            .insert(id, (identity, EnvironmentState::Running));
6587        launcher
6588            .recover_startup(std::slice::from_ref(&harness.policy))
6589            .await
6590            .expect("orphan cannot block host");
6591        assert_eq!(provider.resource_count(), 1);
6592        assert!(
6593            harness
6594                .events
6595                .events()
6596                .contains(&AttemptEvent::QuarantinedOwnedResource { attempt: id })
6597        );
6598        assert_eq!(
6599            provider
6600                .actions
6601                .lock()
6602                .unwrap()
6603                .iter()
6604                .filter(|action| **action == "destroy")
6605                .count(),
6606            0
6607        );
6608
6609        let EnvironmentIdentity::Isolated { environment_id, .. } = provider
6610            .resources
6611            .lock()
6612            .unwrap()
6613            .get(&id)
6614            .unwrap()
6615            .0
6616            .clone()
6617        else {
6618            unreachable!()
6619        };
6620        attempt.begin_prepare(harness.clock.now()).unwrap();
6621        attempt.prepared_environment(environment_id).unwrap();
6622        attempt.mark_prepared(harness.clock.now()).unwrap();
6623        attempt.jit_received(harness.clock.now()).unwrap();
6624        attempt.started_isolated(harness.clock.now()).unwrap();
6625        attempt
6626            .conclude(
6627                AttemptOutcome::failed(FailureReason::ProcessStartFailed),
6628                harness.clock.now(),
6629            )
6630            .unwrap();
6631        attempt.begin_destroy(harness.clock.now()).unwrap();
6632        attempt.clean_isolated(harness.clock.now()).unwrap();
6633        harness.store.record_attempt(&attempt).unwrap();
6634        launcher
6635            .recover_startup(std::slice::from_ref(&harness.policy))
6636            .await
6637            .expect("cleaned row cannot authorize deletion");
6638        assert_eq!(provider.resource_count(), 1);
6639        assert_eq!(
6640            provider
6641                .actions
6642                .lock()
6643                .unwrap()
6644                .iter()
6645                .filter(|action| **action == "destroy")
6646                .count(),
6647            0
6648        );
6649    }
6650
6651    /// The wiring the three-hour outage needed and did not have.
6652    ///
6653    /// A root that refuses a launch does so before `record_allocation`, so no
6654    /// attempt row carries it, and the daemon's log scrubs the paths out of the
6655    /// sentence. The record this asserts is the only surface left, and
6656    /// `service status` reads it -- so if this wiring is ever dropped, the
6657    /// failure goes back to reading `runner_start_failed reason=other` once per
6658    /// poll and nothing else.
6659    #[tokio::test]
6660    async fn a_root_that_refuses_a_launch_is_recorded_and_cleared_when_one_succeeds() {
6661        use runner_manager_platform::service::{clear_runner_root_refusal, runner_root_refusals};
6662
6663        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
6664            .with_host_runner_root();
6665        harness.ready().await;
6666
6667        // A root under two directories that do not exist: refused by the
6668        // preflight, for a reason that has nothing to do with this platform, so
6669        // the assertion holds on all three.
6670        let unusable = harness
6671            ._root
6672            .path()
6673            .join("absent")
6674            .join("deeper")
6675            .join("runners");
6676        let mut host = harness.host.clone();
6677        host.runner_root_override = Some(
6678            LocalAbsolutePath::new(unusable.to_str().expect("a UTF-8 temporary path"))
6679                .expect("a local absolute host root"),
6680        );
6681        harness.store.put_host(&host).unwrap();
6682
6683        let failure = harness
6684            .launch_result()
6685            .await
6686            .expect_err("a root whose parents are missing cannot hold a runner");
6687        assert!(
6688            matches!(failure.reason, FailureReason::Other(_)),
6689            "{failure:?}"
6690        );
6691
6692        let refusals = runner_root_refusals(&harness.app_paths).expect("readable");
6693        let refusal = refusals
6694            .first()
6695            .expect("the refusal reached the one surface that can hold it");
6696        assert_eq!(refusal.policy, harness.policy.id.to_string());
6697        assert_eq!(refusal.kind, "missing_parents");
6698        assert!(
6699            refusal.root.contains("runners") && refusal.detail.contains("runners"),
6700            "the directory must be named in full: {refusal:?}"
6701        );
6702
6703        // And a root that works clears it, so a host that has been fixed stops
6704        // reporting a fault it no longer has.
6705        harness.store.put_host(&harness.host).unwrap();
6706        harness.launch().await;
6707        assert!(
6708            runner_root_refusals(&harness.app_paths)
6709                .expect("readable")
6710                .is_empty(),
6711            "a successful placement clears that policy's record"
6712        );
6713
6714        clear_runner_root_refusal(&harness.app_paths, &harness.policy.id.to_string())
6715            .expect("cleanup");
6716    }
6717
6718    #[tokio::test]
6719    async fn a_launch_reports_how_long_each_step_took() {
6720        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
6721        harness.ready().await;
6722        let started = harness.launch().await;
6723        let events = harness.events.events();
6724        let launched = events
6725            .iter()
6726            .position(|event| matches!(event, AttemptEvent::Launched { attempt, .. } if *attempt == started.id))
6727            .expect("a successful launch reports its step timings");
6728        let starting = events
6729            .iter()
6730            .position(|event| matches!(event, AttemptEvent::State { attempt, state: AttemptState::Starting } if *attempt == started.id))
6731            .unwrap();
6732        assert!(starting < launched, "timings follow the start: {events:?}");
6733    }
6734
6735    #[tokio::test]
6736    async fn a_failed_launch_still_reports_its_step_timings() {
6737        let harness = Harness::new(
6738            FakeGithubLifecycle::default().fail(true),
6739            Arc::new(PersistentDemand),
6740        );
6741        harness.ready().await;
6742        let failed = harness.launch_after_waiting(Duration::from_secs(90)).await;
6743        assert!(failed.is_err());
6744
6745        let timings = harness
6746            .events
6747            .events()
6748            .into_iter()
6749            .find_map(|event| match event {
6750                AttemptEvent::Launched {
6751                    started: false,
6752                    timings,
6753                    ..
6754                } => Some(timings),
6755                _ => None,
6756            })
6757            .expect("a launch that failed at registration reports its timings");
6758        assert_eq!(timings.lock_wait, Duration::from_secs(90));
6759        assert_eq!(timings.spawn, Duration::ZERO, "spawn was never reached");
6760        assert!(timings.total() >= Duration::from_secs(90));
6761    }
6762
6763    #[cfg(target_os = "macos")]
6764    #[tokio::test]
6765    async fn a_launch_removes_the_seed_of_a_root_no_longer_in_use() {
6766        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
6767        harness.ready().await;
6768
6769        // A runtime this host once placed under a root it no longer uses.
6770        let old_root = harness._root.path().join("old-root");
6771        let old_runtime = old_root.join("abandoned");
6772        fs::create_dir_all(old_root.join(PACKAGE_SEED_DIR).join("2.335.0")).unwrap();
6773        fs::create_dir_all(&old_runtime).unwrap();
6774        let old = RunnerAttempt::allocate(
6775            AttemptId::new_random(),
6776            harness.policy.id,
6777            &old_runtime,
6778            harness.clock.now(),
6779        );
6780        harness.store.record_attempt(&old).unwrap();
6781        let kept = harness.host_root().join(PACKAGE_SEED_DIR);
6782        fs::create_dir_all(kept.join("2.336.0")).unwrap();
6783
6784        harness.launch().await;
6785
6786        assert!(
6787            !old_root.join(PACKAGE_SEED_DIR).exists(),
6788            "the abandoned root's seed is removed"
6789        );
6790        assert!(old_runtime.exists(), "only the seed is touched");
6791        assert!(kept.exists(), "the host root's own seed is kept");
6792    }
6793
6794    #[tokio::test]
6795    async fn a_job_walks_every_state_and_cleans_every_artifact() {
6796        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
6797        harness.ready().await;
6798        let started = harness.launch().await;
6799        assert_eq!(started.state(), AttemptState::Starting);
6800        assert_eq!(read_runner_id(started.runtime_path()), Some(73));
6801
6802        harness
6803            .github
6804            .observe(GithubRunnerObservation::Registered { busy: false });
6805        harness.launcher.supervise(&harness.policy).await.unwrap();
6806        assert_eq!(harness.only_attempt().state(), AttemptState::Idle);
6807
6808        harness
6809            .github
6810            .observe(GithubRunnerObservation::Registered { busy: true });
6811        harness.launcher.supervise(&harness.policy).await.unwrap();
6812        assert_eq!(harness.only_attempt().state(), AttemptState::Busy);
6813
6814        harness.processes.finish_successfully();
6815        harness
6816            .github
6817            .observe(GithubRunnerObservation::NotRegistered);
6818        harness.launcher.supervise(&harness.policy).await.unwrap();
6819        let cleaned = harness.only_attempt();
6820        assert_eq!(cleaned.state(), AttemptState::Cleaned);
6821        assert_eq!(cleaned.outcome(), Some(&AttemptOutcome::CompletedJob));
6822        assert!(!started.runtime_path().exists());
6823        assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 1);
6824        assert_eq!(harness.github.remaining_runners.load(Ordering::SeqCst), 0);
6825
6826        let states: Vec<_> = harness
6827            .events
6828            .events()
6829            .into_iter()
6830            .filter_map(|event| match event {
6831                AttemptEvent::State { state, .. } => Some(state),
6832                _ => None,
6833            })
6834            .collect();
6835        assert_eq!(
6836            states,
6837            vec![
6838                AttemptState::Allocated,
6839                AttemptState::JitReceived,
6840                AttemptState::Starting,
6841                AttemptState::Idle,
6842                AttemptState::Busy,
6843                AttemptState::Finished,
6844                AttemptState::Cleaned,
6845            ]
6846        );
6847    }
6848
6849    #[tokio::test]
6850    async fn a_cleaned_ephemeral_attempt_reaps_a_directory_recreated_after_cleanup() {
6851        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
6852        harness.ready().await;
6853        let started = harness.launch().await;
6854        let runtime = started.runtime_path().to_path_buf();
6855
6856        harness
6857            .github
6858            .observe(GithubRunnerObservation::Registered { busy: true });
6859        harness.launcher.supervise(&harness.policy).await.unwrap();
6860        harness.processes.finish_successfully();
6861        harness
6862            .github
6863            .observe(GithubRunnerObservation::NotRegistered);
6864        harness.launcher.supervise(&harness.policy).await.unwrap();
6865
6866        assert_eq!(harness.attempt(started.id).state(), AttemptState::Cleaned);
6867        assert!(!runtime.exists());
6868        assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 1);
6869
6870        // Reproduce a late orphan creating output after the successful removal
6871        // and journal transition. The next ordinary policy supervision must
6872        // remove it without replaying package release or another state change.
6873        let residue = runtime.join("_work").join("late-node-process");
6874        fs::create_dir_all(&residue).unwrap();
6875        fs::write(residue.join("node_modules.lock"), b"late residue").unwrap();
6876
6877        harness.launcher.supervise(&harness.policy).await.unwrap();
6878
6879        assert!(!runtime.exists(), "late ephemeral residue is reaped");
6880        assert_eq!(harness.attempt(started.id).state(), AttemptState::Cleaned);
6881        assert_eq!(
6882            harness.packages.releases.load(Ordering::SeqCst),
6883            1,
6884            "reaping residue does not release the package lease twice"
6885        );
6886    }
6887
6888    #[tokio::test]
6889    async fn locked_late_residue_never_blocks_startup_recovery() {
6890        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
6891        harness.ready().await;
6892        let started = harness.launch().await;
6893        let runtime = started.runtime_path().to_path_buf();
6894
6895        harness
6896            .github
6897            .observe(GithubRunnerObservation::Registered { busy: true });
6898        harness.launcher.supervise(&harness.policy).await.unwrap();
6899        harness.processes.finish_successfully();
6900        harness
6901            .github
6902            .observe(GithubRunnerObservation::NotRegistered);
6903        harness.launcher.supervise(&harness.policy).await.unwrap();
6904        assert_eq!(harness.attempt(started.id).state(), AttemptState::Cleaned);
6905
6906        let held = runtime.join("_work").join("late-node-process");
6907        fs::create_dir_all(&held).unwrap();
6908        fs::write(held.join("node_modules.lock"), b"late residue").unwrap();
6909        let Some(block) = BlockedDeletion::inject(&held) else {
6910            eprintln!(
6911                "skipped: this account cannot be refused a deletion, so locked late residue cannot be injected"
6912            );
6913            return;
6914        };
6915
6916        let restarted = harness.restart();
6917        restarted
6918            .recover_startup(std::slice::from_ref(&harness.policy))
6919            .await
6920            .expect("late residue cannot take the daemon offline");
6921        assert!(runtime.exists(), "the locked residue remains for a retry");
6922        assert!(
6923            harness
6924                .reconcile_events
6925                .events()
6926                .iter()
6927                .any(|event| matches!(
6928                    event,
6929                    LifecycleEvent::AttemptCleanFailed {
6930                        attempt,
6931                        reason: "late_ephemeral_workspace_could_not_be_removed",
6932                        ..
6933                    } if *attempt == started.id
6934                )),
6935            "the non-blocking cleanup failure remains visible"
6936        );
6937
6938        block.release();
6939        // A tolerated cleanup failure is retried after its backoff delay.
6940        harness.clock.advance_secs(31);
6941        restarted
6942            .supervise(&harness.policy)
6943            .await
6944            .expect("the ordinary retry succeeds after the lock is released");
6945        assert!(!runtime.exists(), "the retry removes the late residue");
6946        assert_eq!(harness.attempt(started.id).state(), AttemptState::Cleaned);
6947        assert_eq!(
6948            harness.packages.releases.load(Ordering::SeqCst),
6949            1,
6950            "retrying residue never releases the package lease twice"
6951        );
6952    }
6953
6954    #[tokio::test]
6955    async fn mixed_locked_cleanup_residue_isolated_from_the_whole_startup_pass() {
6956        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
6957        harness.ready().await;
6958
6959        // The first attempt is terminal but its initial cleanup cannot finish.
6960        // This is the state left by the original failed job on the affected
6961        // workstation.
6962        let terminal = harness.launch().await;
6963        let terminal_runtime = terminal.runtime_path().to_path_buf();
6964        harness.conclude(terminal.id);
6965        let Some(terminal_block) = BlockedDeletion::inject(&terminal_runtime.join("late-child"))
6966        else {
6967            eprintln!("skipped: this account cannot inject the two independent deletion refusals");
6968            return;
6969        };
6970
6971        // The second attempt was cleaned successfully, then a child recreated
6972        // part of `_work`. Both real-world residue shapes existed together in
6973        // the incident, and neither may short-circuit recovery of the other.
6974        let cleaned = harness.launch().await;
6975        let cleaned_runtime = cleaned.runtime_path().to_path_buf();
6976        harness.conclude(cleaned.id);
6977        harness
6978            .launcher
6979            .clean(cleaned.id)
6980            .await
6981            .expect("the second attempt initially cleans");
6982        let Some(cleaned_block) =
6983            BlockedDeletion::inject(&cleaned_runtime.join("_work").join("late-child"))
6984        else {
6985            terminal_block.release();
6986            eprintln!("skipped: this account cannot inject the two independent deletion refusals");
6987            return;
6988        };
6989
6990        let restarted = harness.restart();
6991        for _ in 0..2 {
6992            restarted
6993                .recover_startup(std::slice::from_ref(&harness.policy))
6994                .await
6995                .expect("repeated recovery remains ready while both residues are locked");
6996        }
6997        assert_ne!(
6998            harness.attempt(terminal.id).state(),
6999            AttemptState::Cleaned,
7000            "the journal does not claim the terminal workspace was removed"
7001        );
7002        assert_eq!(
7003            harness.attempt(cleaned.id).state(),
7004            AttemptState::Cleaned,
7005            "late residue does not undo an already durable cleaned transition"
7006        );
7007        assert!(terminal_runtime.exists());
7008        assert!(cleaned_runtime.exists());
7009        assert_eq!(
7010            harness.packages.releases.load(Ordering::SeqCst),
7011            1,
7012            "repeated recovery neither loses nor duplicates package leases"
7013        );
7014
7015        terminal_block.release();
7016        cleaned_block.release();
7017        // A tolerated cleanup failure is retried after its backoff delay.
7018        harness.clock.advance_secs(31);
7019        restarted
7020            .supervise(&harness.policy)
7021            .await
7022            .expect("one ordinary pass clears both residues after their locks disappear");
7023        assert!(!terminal_runtime.exists());
7024        assert!(!cleaned_runtime.exists());
7025        assert_eq!(harness.attempt(terminal.id).state(), AttemptState::Cleaned);
7026        assert_eq!(harness.attempt(cleaned.id).state(), AttemptState::Cleaned);
7027        assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 2);
7028    }
7029
7030    #[tokio::test]
7031    async fn a_terminal_attempt_whose_runtime_is_already_gone_recovers_cleanly() {
7032        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7033        harness.ready().await;
7034        let attempt = harness.launch().await;
7035        let runtime = attempt.runtime_path().to_path_buf();
7036        harness.conclude(attempt.id);
7037        fs::remove_dir_all(&runtime).unwrap();
7038
7039        harness
7040            .restart()
7041            .recover_startup(std::slice::from_ref(&harness.policy))
7042            .await
7043            .expect("an already absent disposable runtime is successful cleanup");
7044
7045        assert_eq!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
7046        assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 1);
7047    }
7048
7049    #[tokio::test]
7050    async fn a_non_workspace_cleanup_failure_still_fails_closed_and_can_retry() {
7051        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7052        harness.ready().await;
7053        let attempt = harness.launch().await;
7054        let runtime = attempt.runtime_path().to_path_buf();
7055        harness.conclude(attempt.id);
7056        harness.packages.fail_releases(1);
7057
7058        let restarted = harness.restart();
7059        let failure = restarted
7060            .recover_startup(std::slice::from_ref(&harness.policy))
7061            .await
7062            .expect_err("a package-accounting failure is not safe to downgrade to residue");
7063        assert!(
7064            failure
7065                .reason()
7066                .to_string()
7067                .contains("package lease could not be released")
7068        );
7069        assert!(
7070            !runtime.exists(),
7071            "workspace removal completed before release failed"
7072        );
7073        assert_ne!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
7074
7075        restarted
7076            .recover_startup(std::slice::from_ref(&harness.policy))
7077            .await
7078            .expect("the same journal entry retries safely after the transient failure");
7079        assert_eq!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
7080        assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 1);
7081    }
7082
7083    #[tokio::test]
7084    async fn startup_never_scrubs_a_cleaned_persistent_work_directory() {
7085        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
7086            .with_persistent_workspace(1);
7087        harness.ready().await;
7088        let attempt = harness.launch().await;
7089        let retained = attempt.runtime_path().join("_work").join("checkout-marker");
7090        fs::create_dir_all(retained.parent().unwrap()).unwrap();
7091        fs::write(&retained, b"persistent checkout").unwrap();
7092        harness.cleanup_retaining_work(attempt.id).await;
7093
7094        harness
7095            .restart()
7096            .recover_startup(std::slice::from_ref(&harness.policy))
7097            .await
7098            .expect("a cleaned persistent slot is already reconciled");
7099
7100        assert_eq!(fs::read(&retained).unwrap(), b"persistent checkout");
7101        assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 1);
7102    }
7103
7104    #[tokio::test]
7105    async fn an_idle_exit_is_not_a_failure_in_the_journal_or_events() {
7106        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7107        harness.ready().await;
7108        let started = harness.launch().await;
7109        harness
7110            .github
7111            .observe(GithubRunnerObservation::Registered { busy: false });
7112        harness.launcher.supervise(&harness.policy).await.unwrap();
7113        harness.clock.advance_secs(11);
7114        harness.processes.set_alive(false);
7115        harness
7116            .github
7117            .observe(GithubRunnerObservation::NotRegistered);
7118        harness.launcher.supervise(&harness.policy).await.unwrap();
7119
7120        let cleaned = harness.only_attempt();
7121        assert!(cleaned.outcome().unwrap().is_idle_exit());
7122        assert!(!cleaned.outcome().unwrap().is_failure());
7123        assert!(!started.runtime_path().exists());
7124        assert!(
7125            harness
7126                .reconcile_events
7127                .events()
7128                .iter()
7129                .any(|event| matches!(
7130                    event,
7131                    LifecycleEvent::AttemptCleaned {
7132                        outcome: OutcomeKind::IdleExit,
7133                        ..
7134                    }
7135                ))
7136        );
7137        assert!(!harness.events.events().iter().any(|event| matches!(
7138            event,
7139            AttemptEvent::Concluded {
7140                outcome: OutcomeKind::Failed,
7141                ..
7142            }
7143        )));
7144    }
7145
7146    #[tokio::test]
7147    async fn handoff_is_absent_after_success_and_every_failed_spawn_retry() {
7148        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7149        harness.processes.fail_spawns(2);
7150        harness.ready().await;
7151        let attempt = harness.launch().await;
7152        assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 3);
7153        assert!(harness.processes.saw_secret.load(Ordering::SeqCst));
7154        let names: Vec<_> = fs::read_dir(attempt.runtime_path())
7155            .unwrap()
7156            .map(|entry| entry.unwrap().file_name())
7157            .collect();
7158        assert!(
7159            names.iter().all(|name| {
7160                !name
7161                    .to_string_lossy()
7162                    .starts_with(RestrictiveHandoff::NAME_PREFIX)
7163            }),
7164            "JIT artifact survived: {names:?}"
7165        );
7166        assert_eq!(
7167            *harness.delay.0.lock().unwrap(),
7168            vec![Duration::from_millis(10), Duration::from_millis(20)]
7169        );
7170    }
7171
7172    #[tokio::test]
7173    async fn jit_retry_stops_with_demand_and_a_terminal_403_never_retries() {
7174        let gone = Harness::new(
7175            FakeGithubLifecycle::default().fail(false),
7176            Arc::new(FakeDemand::answering([false])),
7177        );
7178        gone.ready().await;
7179        assert!(gone.launch_result().await.is_err());
7180        assert_eq!(gone.github.registrations.load(Ordering::SeqCst), 1);
7181        assert!(gone.delay.0.lock().unwrap().is_empty());
7182
7183        let forbidden = Harness::new(
7184            FakeGithubLifecycle::default().fail(true),
7185            Arc::new(PersistentDemand),
7186        );
7187        forbidden.ready().await;
7188        assert!(forbidden.launch_result().await.is_err());
7189        assert_eq!(forbidden.github.registrations.load(Ordering::SeqCst), 1);
7190        assert!(forbidden.delay.0.lock().unwrap().is_empty());
7191        assert!(matches!(
7192            forbidden.only_attempt().outcome(),
7193            Some(AttemptOutcome::Failed {
7194                reason: FailureReason::Other(action)
7195            }) if action.contains("403")
7196        ));
7197
7198        let transient = Harness::new(
7199            FakeGithubLifecycle::default().fail(false).fail(false),
7200            Arc::new(PersistentDemand),
7201        );
7202        transient.ready().await;
7203        transient.launch().await;
7204        assert_eq!(transient.github.registrations.load(Ordering::SeqCst), 3);
7205        assert_eq!(
7206            *transient.delay.0.lock().unwrap(),
7207            vec![Duration::from_millis(10), Duration::from_millis(20)]
7208        );
7209    }
7210
7211    /// The layout has to leave room for what the runner writes underneath it.
7212    ///
7213    /// Windows refuses a path over `MAX_PATH`, and this product's own CI hit
7214    /// that: 264 characters against a limit of 260, failing three checkout
7215    /// retries with `Filename too long`. The two identifiers in the old layout
7216    /// cost 74 characters between them for no benefit -- an attempt id is
7217    /// unique on its own.
7218    #[test]
7219    fn a_workspace_leaves_room_for_the_deepest_path_a_checkout_writes() {
7220        const MAX_PATH: usize = 260;
7221        // The real root on the machine this was found on.
7222        let root = r"C:\Users\IvanD\AppData\Local\IvanMurzak\runner-manager\data\runtime";
7223        // What `actions/checkout` writes at its deepest: the work directory,
7224        // the repository named twice, and a pack keep-file with a 40-character
7225        // object name.
7226        let repo = "GitHub-Runner-Scaler-UI";
7227        let deepest = format!(
7228            r"_work\{repo}\{repo}\.git\objects\pack\pack-{}.keep",
7229            "0".repeat(40)
7230        );
7231
7232        let name = workspace_name(AttemptId::new_random());
7233        assert_eq!(name.len(), WORKSPACE_NAME_LEN, "{name}");
7234        assert!(
7235            name.chars().all(|c| c.is_ascii_hexdigit()),
7236            "a directory name must not carry the identifier's dashes: {name}"
7237        );
7238
7239        let full = format!(r"{root}\{name}\{deepest}");
7240        assert!(
7241            full.len() < MAX_PATH,
7242            "the deepest path a checkout writes must fit: {} characters, limit {MAX_PATH}",
7243            full.len()
7244        );
7245
7246        // The discriminator: the layout this replaced does not fit, so a test
7247        // that passed for both would be proving nothing.
7248        let old = format!(
7249            r"{root}\{}\{}\{deepest}",
7250            PolicyId::new_random(),
7251            AttemptId::new_random()
7252        );
7253        assert!(
7254            old.len() > MAX_PATH,
7255            "the old layout is supposed to be the thing that did not fit: {} characters",
7256            old.len()
7257        );
7258    }
7259
7260    #[tokio::test]
7261    async fn two_attempts_never_share_a_workspace_even_after_failure() {
7262        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7263        harness.ready().await;
7264        let first = harness.launch().await;
7265        fs::write(first.runtime_path().join("hostile-leftover"), b"first job").unwrap();
7266        harness
7267            .github
7268            .observe(GithubRunnerObservation::Registered { busy: false });
7269        harness.launcher.supervise(&harness.policy).await.unwrap();
7270        harness.clock.advance_secs(11);
7271        harness.processes.set_alive(false);
7272        harness
7273            .github
7274            .observe(GithubRunnerObservation::NotRegistered);
7275        harness.launcher.supervise(&harness.policy).await.unwrap();
7276        assert!(!first.runtime_path().exists());
7277
7278        let second = harness.launch().await;
7279        assert_ne!(first.runtime_path(), second.runtime_path());
7280        assert!(!second.runtime_path().join("hostile-leftover").exists());
7281
7282        fs::write(
7283            second.runtime_path().join("hostile-on-failure"),
7284            b"second job",
7285        )
7286        .unwrap();
7287        harness.processes.set_alive(false);
7288        harness
7289            .github
7290            .observe(GithubRunnerObservation::NotRegistered);
7291        harness.launcher.supervise(&harness.policy).await.unwrap();
7292        assert!(
7293            !second.runtime_path().exists(),
7294            "failed workspace was retained"
7295        );
7296    }
7297
7298    #[tokio::test]
7299    async fn a_runner_that_never_gets_a_job_is_stopped_deregistered_and_not_replaced() {
7300        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7301        harness.ready().await;
7302        let attempt = harness.launch().await;
7303
7304        // Registered and waiting, which is where it stays: the fake keeps
7305        // answering the same observation, exactly as GitHub does for a runner
7306        // nobody assigns work to.
7307        harness
7308            .github
7309            .observe(GithubRunnerObservation::Registered { busy: false });
7310        harness.launcher.supervise(&harness.policy).await.unwrap();
7311        assert_eq!(harness.only_attempt().state(), AttemptState::Idle);
7312
7313        // One second inside the ten-second idle timeout nothing happens, which
7314        // is what keeps this from being a test that would pass on any clock.
7315        harness.clock.advance_secs(9);
7316        harness
7317            .github
7318            .observe(GithubRunnerObservation::Registered { busy: false });
7319        let none_yet = harness.launcher.supervise(&harness.policy).await.unwrap();
7320        assert_eq!(harness.only_attempt().state(), AttemptState::Idle);
7321        assert!(none_yet.is_empty());
7322        assert_eq!(harness.processes.terminations.load(Ordering::SeqCst), 0);
7323
7324        // Past it, the agent ends the runner itself.
7325        harness.clock.advance_secs(1);
7326        harness
7327            .github
7328            .observe(GithubRunnerObservation::Registered { busy: false });
7329        let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
7330
7331        let concluded = harness.store.attempt(attempt.id).unwrap().unwrap();
7332        assert_eq!(
7333            concluded.outcome(),
7334            Some(&AttemptOutcome::ExitedIdleWithoutWork),
7335            "a surplus runner did not fail; recording one as a failure sends an operator \
7336             hunting a fault that does not exist"
7337        );
7338        assert_eq!(concluded.state(), AttemptState::Cleaned);
7339        assert_eq!(harness.processes.terminations.load(Ordering::SeqCst), 1);
7340        assert!(!attempt.runtime_path().exists());
7341
7342        // The registration goes with it. Without this the runner stays listed
7343        // in the target's runner settings after the process it named is gone.
7344        assert_eq!(
7345            *harness.github.deregistrations.lock().unwrap(),
7346            vec![73],
7347            "the attempt's own runner id, deleted exactly once"
7348        );
7349
7350        // And nothing is started in its place: the work it was launched for went
7351        // elsewhere, so a replacement would rebuild it every idle timeout.
7352        assert!(
7353            replacements.is_empty(),
7354            "a surplus exit must not request a replacement"
7355        );
7356    }
7357
7358    #[tokio::test]
7359    async fn a_registration_github_will_not_delete_still_concludes_the_attempt() {
7360        // The delete is best-effort by construction: the process is gone and the
7361        // slot has to come back. Holding the conclusion until GitHub cooperates
7362        // would leak a capacity slot on every unreachable moment.
7363        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7364        harness.ready().await;
7365        let attempt = harness.launch().await;
7366        harness
7367            .github
7368            .observe(GithubRunnerObservation::Registered { busy: false });
7369        harness.launcher.supervise(&harness.policy).await.unwrap();
7370
7371        harness
7372            .github
7373            .deregistration_fails
7374            .store(true, Ordering::SeqCst);
7375        harness.clock.advance_secs(11);
7376        harness
7377            .github
7378            .observe(GithubRunnerObservation::Registered { busy: false });
7379        harness.launcher.supervise(&harness.policy).await.unwrap();
7380
7381        assert_eq!(
7382            *harness.github.deregistrations.lock().unwrap(),
7383            vec![73],
7384            "the delete was attempted"
7385        );
7386        let concluded = harness.store.attempt(attempt.id).unwrap().unwrap();
7387        assert_eq!(
7388            concluded.outcome(),
7389            Some(&AttemptOutcome::ExitedIdleWithoutWork),
7390            "the attempt concluded anyway"
7391        );
7392        assert_eq!(concluded.state(), AttemptState::Cleaned);
7393        assert!(!attempt.runtime_path().exists());
7394    }
7395
7396    #[tokio::test]
7397    async fn exit_before_acceptance_returns_replacement_intent_without_launching() {
7398        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7399        harness.ready().await;
7400        let first = harness.launch().await;
7401        harness.processes.set_alive(false);
7402        harness
7403            .github
7404            .observe(GithubRunnerObservation::NotRegistered);
7405        let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
7406        let failed = harness.store.attempt(first.id).unwrap().unwrap();
7407        assert!(matches!(
7408            failed.outcome(),
7409            Some(AttemptOutcome::Failed {
7410                reason: FailureReason::ProcessExitedUnexpectedly
7411            })
7412        ));
7413        assert!(!first.runtime_path().exists());
7414
7415        assert_eq!(
7416            replacements,
7417            vec![ReplacementIntent {
7418                policy: harness.policy.id,
7419                previous_attempt: first.id,
7420                operation: "exit_before_acceptance_replacement",
7421            }]
7422        );
7423        assert_eq!(harness.store.attempts().unwrap().len(), 1);
7424        assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 1);
7425        assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 1);
7426        assert!(harness.delay.0.lock().unwrap().is_empty());
7427    }
7428
7429    #[tokio::test]
7430    async fn expired_jit_is_removed_and_does_not_reregister_after_demand_disappears() {
7431        let harness = Harness::new(
7432            FakeGithubLifecycle::default(),
7433            Arc::new(FakeDemand::answering([false])),
7434        );
7435        let id = AttemptId::new_random();
7436        let runtime = harness
7437            .launcher
7438            .app_paths
7439            .runtime_dir()
7440            .join(harness.policy.id.to_string())
7441            .join(id.to_string());
7442        fs::create_dir_all(&runtime).unwrap();
7443        let mut attempt =
7444            RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
7445        attempt.jit_received(harness.clock.now()).unwrap();
7446        harness.store.record_attempt(&attempt).unwrap();
7447        harness.clock.advance_secs(11);
7448        let replacements = harness
7449            .launcher
7450            .recover_startup(std::slice::from_ref(&harness.policy))
7451            .await
7452            .unwrap();
7453        assert_eq!(
7454            replacements,
7455            vec![ReplacementIntent {
7456                policy: harness.policy.id,
7457                previous_attempt: id,
7458                operation: "jit_expired_replacement",
7459            }]
7460        );
7461
7462        let cleaned = harness.store.attempt(id).unwrap().unwrap();
7463        assert_eq!(cleaned.state(), AttemptState::Cleaned);
7464        assert!(matches!(
7465            cleaned.outcome(),
7466            Some(AttemptOutcome::Failed {
7467                reason: FailureReason::JitExpired
7468            })
7469        ));
7470        assert!(!runtime.exists());
7471        assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
7472        assert!(harness.delay.0.lock().unwrap().is_empty());
7473    }
7474
7475    #[tokio::test]
7476    async fn expired_jit_returns_intent_but_never_launches_inside_lifecycle() {
7477        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7478        let id = AttemptId::new_random();
7479        let runtime = harness
7480            .launcher
7481            .app_paths
7482            .runtime_dir()
7483            .join("expired-with-demand");
7484        fs::create_dir_all(&runtime).unwrap();
7485        let mut attempt =
7486            RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
7487        attempt.jit_received(harness.clock.now()).unwrap();
7488        harness.store.record_attempt(&attempt).unwrap();
7489        harness.clock.advance_secs(11);
7490        let replacements = harness
7491            .launcher
7492            .recover_startup(std::slice::from_ref(&harness.policy))
7493            .await
7494            .unwrap();
7495
7496        let attempts = harness.store.attempts().unwrap();
7497        assert_eq!(attempts.len(), 1);
7498        assert_eq!(
7499            attempts
7500                .iter()
7501                .find(|attempt| attempt.id == id)
7502                .unwrap()
7503                .state(),
7504            AttemptState::Cleaned
7505        );
7506        assert_eq!(
7507            replacements,
7508            vec![ReplacementIntent {
7509                policy: harness.policy.id,
7510                previous_attempt: id,
7511                operation: "jit_expired_replacement",
7512            }]
7513        );
7514        assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
7515        assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 0);
7516        assert!(harness.delay.0.lock().unwrap().is_empty());
7517    }
7518
7519    #[tokio::test]
7520    async fn package_materialization_retries_are_bounded_and_demand_adjacent() {
7521        let persistent = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7522        persistent.packages.fail_materializations(2);
7523        persistent.ready().await;
7524        persistent.launch().await;
7525        assert_eq!(
7526            persistent.packages.materializations.load(Ordering::SeqCst),
7527            3
7528        );
7529        assert_eq!(
7530            *persistent.delay.0.lock().unwrap(),
7531            vec![Duration::from_millis(10), Duration::from_millis(20)]
7532        );
7533
7534        let gone_before_wait = Harness::new(
7535            FakeGithubLifecycle::default(),
7536            Arc::new(FakeDemand::answering([false])),
7537        );
7538        gone_before_wait.packages.fail_materializations(3);
7539        gone_before_wait.ready().await;
7540        assert!(gone_before_wait.launch_result().await.is_err());
7541        assert_eq!(
7542            gone_before_wait
7543                .packages
7544                .materializations
7545                .load(Ordering::SeqCst),
7546            1
7547        );
7548        assert!(gone_before_wait.delay.0.lock().unwrap().is_empty());
7549
7550        let gone_during_wait = Harness::new(
7551            FakeGithubLifecycle::default(),
7552            Arc::new(FakeDemand::answering([true, false])),
7553        );
7554        gone_during_wait.packages.fail_materializations(3);
7555        gone_during_wait.ready().await;
7556        assert!(gone_during_wait.launch_result().await.is_err());
7557        assert_eq!(
7558            gone_during_wait
7559                .packages
7560                .materializations
7561                .load(Ordering::SeqCst),
7562            1
7563        );
7564        assert_eq!(
7565            *gone_during_wait.delay.0.lock().unwrap(),
7566            vec![Duration::from_millis(10)]
7567        );
7568    }
7569
7570    #[tokio::test]
7571    async fn replacement_is_intent_only_and_never_launches_inside_lifecycle() {
7572        let harness = Harness::new(
7573            FakeGithubLifecycle::default(),
7574            Arc::new(FakeDemand::answering([true, false])),
7575        );
7576        harness.ready().await;
7577        let first = harness.launch().await;
7578        harness.processes.set_alive(false);
7579        harness
7580            .github
7581            .observe(GithubRunnerObservation::NotRegistered);
7582        let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
7583
7584        assert_eq!(harness.store.attempts().unwrap().len(), 1);
7585        assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 1);
7586        assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 1);
7587        assert!(harness.delay.0.lock().unwrap().is_empty());
7588        assert_eq!(
7589            replacements,
7590            vec![ReplacementIntent {
7591                policy: harness.policy.id,
7592                previous_attempt: first.id,
7593                operation: "exit_before_acceptance_replacement",
7594            }]
7595        );
7596        assert_eq!(
7597            harness.store.attempt(first.id).unwrap().unwrap().state(),
7598            AttemptState::Cleaned
7599        );
7600    }
7601
7602    #[tokio::test]
7603    async fn startup_adopts_a_live_process_and_refuses_launch_before_recovery() {
7604        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7605        let before = harness.launch_result().await;
7606        assert!(before.is_err());
7607        assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 0);
7608
7609        let id = AttemptId::new_random();
7610        let runtime = harness.launcher.app_paths.runtime_dir().join("adopt");
7611        fs::create_dir_all(&runtime).unwrap();
7612        let mut attempt =
7613            RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
7614        attempt.jit_received(harness.clock.now()).unwrap();
7615        attempt.started(4242, harness.clock.now()).unwrap();
7616        harness.store.record_attempt(&attempt).unwrap();
7617        harness.processes.set_alive(true);
7618        harness
7619            .github
7620            .observe(GithubRunnerObservation::NotRegistered);
7621        let replacements = harness
7622            .launcher
7623            .recover_startup(std::slice::from_ref(&harness.policy))
7624            .await
7625            .unwrap();
7626        assert!(replacements.is_empty());
7627        assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 0);
7628        assert!(
7629            harness
7630                .events
7631                .events()
7632                .contains(&AttemptEvent::Adopted { attempt: id })
7633        );
7634    }
7635
7636    #[tokio::test]
7637    async fn spawn_before_starting_crash_recovers_pid_then_completes_and_cleans() {
7638        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7639        let id = AttemptId::new_random();
7640        let runtime = harness
7641            .launcher
7642            .app_paths
7643            .runtime_dir()
7644            .join("spawn-before-starting");
7645        fs::create_dir_all(&runtime).unwrap();
7646        let mut attempt =
7647            RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
7648        attempt.jit_received(harness.clock.now()).unwrap();
7649        harness.store.record_attempt(&attempt).unwrap();
7650        harness.processes.set_alive(true);
7651        harness
7652            .github
7653            .observe(GithubRunnerObservation::Registered { busy: true });
7654
7655        let replacements = harness
7656            .launcher
7657            .recover_startup(std::slice::from_ref(&harness.policy))
7658            .await
7659            .unwrap();
7660        assert!(replacements.is_empty());
7661        let recovered = harness.store.attempt(id).unwrap().unwrap();
7662        assert_eq!(recovered.state(), AttemptState::Busy);
7663        assert_eq!(recovered.process_id(), Some(4242));
7664        assert_eq!(recovered.github_runner_id(), Some(73));
7665        let events = harness.events.events();
7666        let starting = events
7667            .iter()
7668            .position(|event| matches!(event, AttemptEvent::State { attempt, state: AttemptState::Starting } if *attempt == id))
7669            .unwrap();
7670        let busy = events
7671            .iter()
7672            .position(|event| matches!(event, AttemptEvent::State { attempt, state: AttemptState::Busy } if *attempt == id))
7673            .unwrap();
7674        assert!(starting < busy, "recovery skipped a legal edge: {events:?}");
7675
7676        harness.processes.finish_successfully();
7677        harness
7678            .github
7679            .observe(GithubRunnerObservation::NotRegistered);
7680        assert!(
7681            harness
7682                .launcher
7683                .supervise(&harness.policy)
7684                .await
7685                .unwrap()
7686                .is_empty()
7687        );
7688        let cleaned = harness.store.attempt(id).unwrap().unwrap();
7689        assert_eq!(cleaned.state(), AttemptState::Cleaned);
7690        assert_eq!(cleaned.outcome(), Some(&AttemptOutcome::CompletedJob));
7691        assert!(!runtime.exists());
7692    }
7693
7694    #[tokio::test]
7695    async fn failed_post_spawn_stop_keeps_capacity_until_supervision_proves_death() {
7696        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7697        harness.processes.fail_spawn_with_live_child();
7698        harness.ready().await;
7699        assert!(harness.launch_result().await.is_err());
7700
7701        let attempt = harness.only_attempt();
7702        assert_eq!(attempt.state(), AttemptState::Starting);
7703        assert_eq!(attempt.process_id(), Some(4242));
7704        assert!(attempt.outcome().is_none());
7705        assert!(attempt.state().counts_against_capacity());
7706        assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 1);
7707        assert!(harness.delay.0.lock().unwrap().is_empty());
7708
7709        harness.processes.set_alive(false);
7710        harness
7711            .github
7712            .observe(GithubRunnerObservation::NotRegistered);
7713        let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
7714        assert_eq!(replacements.len(), 1);
7715        assert_eq!(
7716            harness.store.attempt(attempt.id).unwrap().unwrap().state(),
7717            AttemptState::Cleaned
7718        );
7719    }
7720
7721    #[tokio::test]
7722    async fn remote_runner_identity_closes_both_sides_of_the_registration_crash_boundary() {
7723        for sidecar_already_present in [false, true] {
7724            let harness = Harness::new(
7725                FakeGithubLifecycle::default(),
7726                Arc::new(FakeDemand::answering([false])),
7727            );
7728            let id = AttemptId::new_random();
7729            let runtime =
7730                harness
7731                    .launcher
7732                    .app_paths
7733                    .runtime_dir()
7734                    .join(if sidecar_already_present {
7735                        "after-id-sidecar"
7736                    } else {
7737                        "before-id-sidecar"
7738                    });
7739            fs::create_dir_all(&runtime).unwrap();
7740            let mut attempt =
7741                RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
7742            if sidecar_already_present {
7743                write_runner_id(&runtime, 73).unwrap();
7744                attempt.jit_received(harness.clock.now()).unwrap();
7745            }
7746            harness.store.record_attempt(&attempt).unwrap();
7747            harness.processes.set_alive(true);
7748            harness
7749                .github
7750                .observe(GithubRunnerObservation::Registered { busy: false });
7751            harness
7752                .launcher
7753                .recover_startup(std::slice::from_ref(&harness.policy))
7754                .await
7755                .unwrap();
7756
7757            assert_eq!(read_runner_id(&runtime), Some(73));
7758            assert!(
7759                harness
7760                    .store
7761                    .attempt(id)
7762                    .unwrap()
7763                    .unwrap()
7764                    .outcome()
7765                    .is_none()
7766            );
7767            let events = harness.events.events();
7768            let recovered = events.iter().position(|event| {
7769                matches!(
7770                    event,
7771                    AttemptEvent::RemoteIdentityRecovered {
7772                        attempt,
7773                        runner_id: 73
7774                    } if *attempt == id
7775                )
7776            });
7777            assert_eq!(recovered.is_some(), !sidecar_already_present);
7778            if let Some(recovered) = recovered {
7779                let adopted = events
7780                    .iter()
7781                    .position(|event| matches!(event, AttemptEvent::Adopted { attempt } if *attempt == id))
7782                    .unwrap();
7783                assert!(
7784                    recovered < adopted,
7785                    "identity was not durable before adoption: {events:?}"
7786                );
7787            }
7788            assert!(runtime.exists());
7789        }
7790    }
7791
7792    #[tokio::test]
7793    async fn recovery_stays_closed_for_unknown_policy_and_unreachable_attempts() {
7794        let unknown = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7795        let unknown_attempt = RunnerAttempt::allocate(
7796            AttemptId::new_random(),
7797            PolicyId::from_u128(0xfeed),
7798            unknown
7799                .launcher
7800                .app_paths
7801                .runtime_dir()
7802                .join("unknown-policy"),
7803            unknown.clock.now(),
7804        );
7805        unknown.store.record_attempt(&unknown_attempt).unwrap();
7806        let expired_id = AttemptId::new_random();
7807        let expired_runtime = unknown
7808            .launcher
7809            .app_paths
7810            .runtime_dir()
7811            .join("expired-beside-unknown");
7812        fs::create_dir_all(&expired_runtime).unwrap();
7813        let mut expired = RunnerAttempt::allocate(
7814            expired_id,
7815            unknown.policy.id,
7816            expired_runtime,
7817            unknown.clock.now(),
7818        );
7819        expired.jit_received(unknown.clock.now()).unwrap();
7820        unknown.store.record_attempt(&expired).unwrap();
7821        unknown.clock.advance_secs(11);
7822        assert!(matches!(
7823            unknown
7824                .launcher
7825                .recover_startup(std::slice::from_ref(&unknown.policy))
7826                .await,
7827            Err(LifecycleError::RecoveryIncomplete)
7828        ));
7829        assert!(unknown.launch_result().await.is_err());
7830        assert_eq!(unknown.processes.spawns.load(Ordering::SeqCst), 0);
7831        let recovered_policy = fixtures::policy()
7832            .id(PolicyId::from_u128(0xfeed))
7833            .repository("octo/repo")
7834            .autoscale("home", 2)
7835            .active()
7836            .build();
7837        let pending = unknown
7838            .launcher
7839            .recover_startup(&[unknown.policy.clone(), recovered_policy])
7840            .await
7841            .unwrap();
7842        assert_eq!(
7843            pending,
7844            vec![ReplacementIntent {
7845                policy: unknown.policy.id,
7846                previous_attempt: expired_id,
7847                operation: "jit_expired_replacement",
7848            }]
7849        );
7850        assert_eq!(unknown.processes.spawns.load(Ordering::SeqCst), 0);
7851
7852        let unreachable = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7853        let id = AttemptId::new_random();
7854        let runtime = unreachable
7855            .launcher
7856            .app_paths
7857            .runtime_dir()
7858            .join("unreachable");
7859        fs::create_dir_all(&runtime).unwrap();
7860        unreachable
7861            .store
7862            .record_attempt(&RunnerAttempt::allocate(
7863                id,
7864                unreachable.policy.id,
7865                runtime,
7866                unreachable.clock.now(),
7867            ))
7868            .unwrap();
7869        unreachable
7870            .github
7871            .observe(GithubRunnerObservation::Unreachable);
7872        assert!(matches!(
7873            unreachable
7874                .launcher
7875                .recover_startup(std::slice::from_ref(&unreachable.policy))
7876                .await,
7877            Err(LifecycleError::RecoveryIncomplete)
7878        ));
7879        assert!(unreachable.launch_result().await.is_err());
7880        assert_eq!(unreachable.processes.spawns.load(Ordering::SeqCst), 0);
7881    }
7882
7883    #[tokio::test]
7884    async fn a_dead_busy_process_unknown_to_github_is_orphaned_and_cleaned() {
7885        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7886        let id = AttemptId::new_random();
7887        let runtime = harness.launcher.app_paths.runtime_dir().join("orphan");
7888        fs::create_dir_all(&runtime).unwrap();
7889        let mut attempt =
7890            RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
7891        attempt.jit_received(harness.clock.now()).unwrap();
7892        attempt.started(4242, harness.clock.now()).unwrap();
7893        attempt.assigned_job(73, harness.clock.now()).unwrap();
7894        harness.store.record_attempt(&attempt).unwrap();
7895        harness.processes.set_alive(false);
7896        harness
7897            .github
7898            .observe(GithubRunnerObservation::NotRegistered);
7899        harness
7900            .launcher
7901            .recover_startup(std::slice::from_ref(&harness.policy))
7902            .await
7903            .unwrap();
7904        let cleaned = harness.store.attempt(id).unwrap().unwrap();
7905        assert_eq!(cleaned.state(), AttemptState::Cleaned);
7906        assert_eq!(cleaned.outcome(), Some(&AttemptOutcome::Orphaned));
7907        assert!(!runtime.exists());
7908    }
7909
7910    #[tokio::test]
7911    async fn registration_timeout_journals_intent_stops_then_concludes_with_dead_reason() {
7912        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7913        harness.ready().await;
7914        let id = AttemptId::new_random();
7915        let runtime = harness.launcher.app_paths.runtime_dir().join("timeout");
7916        fs::create_dir_all(&runtime).unwrap();
7917        let mut attempt =
7918            RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
7919        attempt.jit_received(harness.clock.now()).unwrap();
7920        attempt.started(4242, harness.clock.now()).unwrap();
7921        harness.store.record_attempt(&attempt).unwrap();
7922        harness.clock.advance_secs(11);
7923        harness.processes.set_alive(true);
7924        harness
7925            .github
7926            .observe(GithubRunnerObservation::NotRegistered);
7927        let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
7928        assert_eq!(
7929            replacements,
7930            vec![ReplacementIntent {
7931                policy: harness.policy.id,
7932                previous_attempt: id,
7933                operation: "registration_timeout_replacement",
7934            }]
7935        );
7936
7937        assert_eq!(harness.processes.terminations.load(Ordering::SeqCst), 1);
7938        assert!(!harness.processes.alive.load(Ordering::SeqCst));
7939        let actions = harness.processes.actions.lock().unwrap().clone();
7940        let intent = actions
7941            .iter()
7942            .position(|action| *action == "terminate_intent")
7943            .unwrap();
7944        let signal = actions
7945            .iter()
7946            .position(|action| *action == "terminate")
7947            .unwrap();
7948        assert!(
7949            intent < signal,
7950            "intent was not durable before signal: {actions:?}"
7951        );
7952
7953        let cleaned = harness.store.attempt(id).unwrap().unwrap();
7954        assert!(matches!(
7955            cleaned.outcome(),
7956            Some(AttemptOutcome::Failed {
7957                reason: FailureReason::TerminatedAfterRegistrationTimeout
7958            })
7959        ));
7960        let events = harness.events.events();
7961        let intent = events
7962            .iter()
7963            .position(|event| matches!(event, AttemptEvent::TerminateIntent { .. }))
7964            .unwrap();
7965        let stopped = events
7966            .iter()
7967            .position(|event| matches!(event, AttemptEvent::Terminated { .. }))
7968            .unwrap();
7969        let concluded = events
7970            .iter()
7971            .position(|event| matches!(event, AttemptEvent::Concluded { .. }))
7972            .unwrap();
7973        assert!(intent < stopped && stopped < concluded, "{events:?}");
7974    }
7975
7976    #[tokio::test]
7977    async fn timeout_crash_recovery_returns_the_same_replacement_intent() {
7978        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7979        let id = AttemptId::new_random();
7980        let runtime = harness
7981            .launcher
7982            .app_paths
7983            .runtime_dir()
7984            .join("timeout-after-crash");
7985        fs::create_dir_all(&runtime).unwrap();
7986        let mut attempt =
7987            RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
7988        attempt.jit_received(harness.clock.now()).unwrap();
7989        attempt.started(4242, harness.clock.now()).unwrap();
7990        harness.store.record_attempt(&attempt).unwrap();
7991        harness.processes.intent.store(true, Ordering::SeqCst);
7992        harness.processes.set_alive(false);
7993        harness
7994            .github
7995            .observe(GithubRunnerObservation::NotRegistered);
7996
7997        let replacements = harness
7998            .launcher
7999            .recover_startup(std::slice::from_ref(&harness.policy))
8000            .await
8001            .unwrap();
8002        assert_eq!(
8003            replacements,
8004            vec![ReplacementIntent {
8005                policy: harness.policy.id,
8006                previous_attempt: id,
8007                operation: "registration_timeout_replacement",
8008            }]
8009        );
8010        let consumed = RunnerLauncher::supervise(&harness.launcher, &harness.policy)
8011            .await
8012            .unwrap();
8013        assert_eq!(consumed, replacements);
8014        assert!(
8015            RunnerLauncher::supervise(&harness.launcher, &harness.policy)
8016                .await
8017                .unwrap()
8018                .is_empty(),
8019            "startup replacement evidence must be consumed exactly once by e1"
8020        );
8021        assert!(matches!(
8022            harness.store.attempt(id).unwrap().unwrap().outcome(),
8023            Some(AttemptOutcome::Failed {
8024                reason: FailureReason::TerminatedAfterRegistrationTimeout
8025            })
8026        ));
8027    }
8028
8029    #[tokio::test]
8030    async fn terminate_intent_sync_failure_prevents_signal_and_conclusion() {
8031        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8032        let id = AttemptId::new_random();
8033        let runtime = harness
8034            .launcher
8035            .app_paths
8036            .runtime_dir()
8037            .join("timeout-sync-failure");
8038        fs::create_dir_all(&runtime).unwrap();
8039        let mut attempt =
8040            RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
8041        attempt.jit_received(harness.clock.now()).unwrap();
8042        attempt.started(4242, harness.clock.now()).unwrap();
8043        harness.store.record_attempt(&attempt).unwrap();
8044        harness.clock.advance_secs(11);
8045        harness.processes.set_alive(true);
8046        harness.processes.fail_intent();
8047        harness
8048            .github
8049            .observe(GithubRunnerObservation::NotRegistered);
8050
8051        assert!(
8052            harness
8053                .launcher
8054                .recover_startup(std::slice::from_ref(&harness.policy))
8055                .await
8056                .is_err()
8057        );
8058        assert_eq!(harness.processes.terminations.load(Ordering::SeqCst), 0);
8059        assert!(harness.processes.alive.load(Ordering::SeqCst));
8060        assert_eq!(
8061            harness.store.attempt(id).unwrap().unwrap().state(),
8062            AttemptState::Starting
8063        );
8064        assert!(!harness.events.events().iter().any(|event| matches!(
8065            event,
8066            AttemptEvent::Terminated { attempt } | AttemptEvent::Concluded { attempt, .. }
8067                if *attempt == id
8068        )));
8069    }
8070
8071    #[tokio::test]
8072    async fn diagnostics_survive_cleanup_without_the_jit_or_a_token() {
8073        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8074        harness.ready().await;
8075        let attempt = harness.launch().await;
8076        harness
8077            .github
8078            .observe(GithubRunnerObservation::Registered { busy: false });
8079        harness.launcher.supervise(&harness.policy).await.unwrap();
8080        harness.clock.advance_secs(11);
8081        harness.processes.set_alive(false);
8082        harness
8083            .github
8084            .observe(GithubRunnerObservation::NotRegistered);
8085        harness.launcher.supervise(&harness.policy).await.unwrap();
8086        let diagnostic = fs::read_to_string(
8087            harness
8088                .launcher
8089                .diagnostics_root
8090                .join(format!("{}.log", attempt.id)),
8091        )
8092        .unwrap();
8093        assert!(diagnostic.contains("exited_idle_without_work"));
8094        assert!(!diagnostic.contains(JIT));
8095        assert!(!diagnostic.contains("ghp_"));
8096        assert!(!attempt.runtime_path().exists());
8097    }
8098
8099    #[test]
8100    fn native_process_listing_never_contains_jit_and_handoffs_never_survive() {
8101        let root = tempfile::tempdir().unwrap();
8102        let policy = fixtures::policy()
8103            .repository("octo/repo")
8104            .autoscale("home", 1)
8105            .active()
8106            .build();
8107        let runtime = root.path().join("successful");
8108        fs::create_dir_all(&runtime).unwrap();
8109        let processes = NativeProcesses::new();
8110        let config = EncodedJitConfig::new(JIT);
8111        let handoff =
8112            RestrictiveHandoff::create(&runtime, SecretString::from(config.expose().to_owned()))
8113                .unwrap();
8114        let mut child = native_inspection_spec()
8115            .spawn_runner_with_handoff(&handoff)
8116            .expect("native child starts");
8117        let pid = child.pid();
8118        handoff.delete().unwrap();
8119        let command_line = native_command_line(pid);
8120        assert!(
8121            !command_line.contains(JIT),
8122            "the encoded JIT configuration appeared in the native process listing"
8123        );
8124        assert_no_jit_file(&runtime);
8125        child
8126            .stop(Duration::from_secs(1))
8127            .expect("native child stops");
8128
8129        let failed_runtime = root.path().join("failed");
8130        fs::create_dir_all(&failed_runtime).unwrap();
8131        let failed = RunnerAttempt::allocate(
8132            AttemptId::new_random(),
8133            policy.id,
8134            &failed_runtime,
8135            FakeClock::default().now(),
8136        );
8137        assert!(
8138            processes
8139                .spawn(&failed, &EncodedJitConfig::new(JIT))
8140                .is_err(),
8141            "a runtime with no runner executable must fail"
8142        );
8143        assert_no_jit_file(&failed_runtime);
8144        processes
8145            .record_terminate_intent(&failed)
8146            .expect("the intent file and its directory entry are durably synced");
8147        assert_eq!(
8148            fs::read(NativeProcesses::intent_path(&failed)).unwrap(),
8149            b"registration-timeout\n"
8150        );
8151    }
8152
8153    #[test]
8154    fn post_spawn_boundaries_are_bounded_durable_and_never_retry_jit() {
8155        let root = tempfile::tempdir().unwrap();
8156        let policy = fixtures::policy()
8157            .repository("octo/repo")
8158            .autoscale("home", 1)
8159            .active()
8160            .build();
8161        let processes = NativeProcesses::new();
8162        processes.use_long_lived_test_listener();
8163        for (index, boundary) in [
8164            PostSpawnBoundary::HandoffDelete,
8165            PostSpawnBoundary::IdentitySerialize,
8166            PostSpawnBoundary::IdentityWrite,
8167            PostSpawnBoundary::ChildMapInsert,
8168        ]
8169        .into_iter()
8170        .enumerate()
8171        {
8172            let runtime = root.path().join(format!("post-spawn-{index}"));
8173            let bin = runtime.join("bin");
8174            fs::create_dir_all(&bin).unwrap();
8175            #[cfg(windows)]
8176            let listener = bin.join("Runner.Listener.exe");
8177            #[cfg(not(windows))]
8178            let listener = bin.join("Runner.Listener");
8179            fs::copy(std::env::current_exe().unwrap(), &listener).unwrap();
8180            let attempt = RunnerAttempt::allocate(
8181                AttemptId::new_random(),
8182                policy.id,
8183                &runtime,
8184                FakeClock::default().now(),
8185            );
8186            processes.fail_post_spawn_at(boundary);
8187            let failure = processes
8188                .spawn(&attempt, &EncodedJitConfig::new(JIT))
8189                .expect_err("fault must cross the post-spawn cleanup path");
8190            assert!(!failure.retryable, "{boundary:?} allowed duplicate retry");
8191            assert!(
8192                !processes.is_alive(&attempt).unwrap(),
8193                "{boundary:?} left a child"
8194            );
8195            assert!(!NativeProcesses::identity_path(&attempt).exists());
8196            assert_no_jit_file(&runtime);
8197        }
8198        assert_eq!(processes.post_spawn_reaps.load(Ordering::SeqCst), 4);
8199
8200        let runtime = root.path().join("identity-and-stop-fail");
8201        let bin = runtime.join("bin");
8202        fs::create_dir_all(&bin).unwrap();
8203        #[cfg(windows)]
8204        let listener = bin.join("Runner.Listener.exe");
8205        #[cfg(not(windows))]
8206        let listener = bin.join("Runner.Listener");
8207        fs::copy(std::env::current_exe().unwrap(), &listener).unwrap();
8208        let attempt = RunnerAttempt::allocate(
8209            AttemptId::new_random(),
8210            policy.id,
8211            &runtime,
8212            FakeClock::default().now(),
8213        );
8214        // The first fault rejects the normal identity write; the second rejects
8215        // its retry after the first stop fails. The fallback sidecar must make
8216        // the live-child result durable without an unbounded reap loop.
8217        processes.fail_post_spawn_at(PostSpawnBoundary::IdentityWrite);
8218        processes.fail_post_spawn_at(PostSpawnBoundary::IdentityWrite);
8219        processes.fail_next_post_spawn_stop();
8220        let failure = processes
8221            .spawn(&attempt, &EncodedJitConfig::new(JIT))
8222            .expect_err("the identity boundary must fail closed");
8223        assert!(failure.live_pid.is_some());
8224        assert_long_lived_listener_ready(&processes, &attempt);
8225        assert!(processes.is_alive(&attempt).unwrap());
8226        assert!(!NativeProcesses::identity_path(&attempt).exists());
8227        assert!(NativeProcesses::fallback_identity_path(&attempt).is_file());
8228        assert_eq!(processes.post_spawn_reaps.load(Ordering::SeqCst), 4);
8229        processes.terminate(&attempt).unwrap();
8230
8231        let runtime = root.path().join("persistent-stop-and-identity-failures");
8232        let bin = runtime.join("bin");
8233        fs::create_dir_all(&bin).unwrap();
8234        #[cfg(windows)]
8235        let listener = bin.join("Runner.Listener.exe");
8236        #[cfg(not(windows))]
8237        let listener = bin.join("Runner.Listener");
8238        fs::copy(std::env::current_exe().unwrap(), &listener).unwrap();
8239        let mut unresolved = RunnerAttempt::allocate(
8240            AttemptId::new_random(),
8241            policy.id,
8242            &runtime,
8243            FakeClock::default().now(),
8244        );
8245        for _ in 0..3 {
8246            processes.fail_post_spawn_at(PostSpawnBoundary::IdentityWrite);
8247        }
8248        processes.fail_post_spawn_stops(MAX_POST_SPAWN_STOP_ATTEMPTS);
8249        let failure = processes
8250            .spawn(&unresolved, &EncodedJitConfig::new(JIT))
8251            .expect_err("bounded cleanup must return even when every stop errors");
8252        let pid = failure
8253            .live_pid
8254            .expect("the owned child remains supervised in this invocation");
8255        assert!(matches!(failure.reason, FailureReason::Other(_)));
8256        assert_long_lived_listener_ready(&processes, &unresolved);
8257        unresolved.jit_received(FakeClock::default().now()).unwrap();
8258        unresolved.started(pid, FakeClock::default().now()).unwrap();
8259        let journal = SqliteStore::open_in_memory().unwrap();
8260        journal.record_attempt(&unresolved).unwrap();
8261        let recovered = journal.attempt(unresolved.id).unwrap().unwrap();
8262        assert_eq!(recovered.process_id(), Some(pid));
8263        assert_eq!(recovered.state(), AttemptState::Starting);
8264        assert!(processes.is_alive(&unresolved).unwrap());
8265        assert!(!NativeProcesses::identity_path(&unresolved).exists());
8266        assert!(!NativeProcesses::fallback_identity_path(&unresolved).exists());
8267        assert_eq!(
8268            fs::read_to_string(NativeProcesses::unresolved_process_path(&unresolved)).unwrap(),
8269            pid.to_string(),
8270            "bounded cleanup must leave durable unresolved-process evidence before returning"
8271        );
8272        assert!(
8273            NativeProcesses::new().is_alive(&recovered).is_err(),
8274            "restart must fail closed on the durable starting/PID journal rather than trust a bare PID"
8275        );
8276        processes.terminate(&unresolved).unwrap();
8277
8278        let runtime = root.path().join("post-spawn-stop-failed");
8279        let bin = runtime.join("bin");
8280        fs::create_dir_all(&bin).unwrap();
8281        #[cfg(windows)]
8282        let listener = bin.join("Runner.Listener.exe");
8283        #[cfg(not(windows))]
8284        let listener = bin.join("Runner.Listener");
8285        fs::copy(std::env::current_exe().unwrap(), &listener).unwrap();
8286        let attempt = RunnerAttempt::allocate(
8287            AttemptId::new_random(),
8288            policy.id,
8289            &runtime,
8290            FakeClock::default().now(),
8291        );
8292        processes.fail_post_spawn_at(PostSpawnBoundary::ChildMapInsert);
8293        processes.fail_next_post_spawn_stop();
8294        let failure = processes
8295            .spawn(&attempt, &EncodedJitConfig::new(JIT))
8296            .expect_err("the injected stop failure must preserve supervision");
8297        let live_pid = failure
8298            .live_pid
8299            .expect("live PID is returned to the journal");
8300        assert!(!failure.retryable);
8301        assert_long_lived_listener_ready(&processes, &attempt);
8302        assert!(NativeProcesses::identity_path(&attempt).is_file());
8303        assert_eq!(
8304            NativeProcesses::read_identity(&attempt)
8305                .unwrap()
8306                .unwrap()
8307                .pid(),
8308            live_pid
8309        );
8310        assert_eq!(processes.post_spawn_reaps.load(Ordering::SeqCst), 4);
8311        processes.terminate(&attempt).unwrap();
8312    }
8313
8314    #[test]
8315    #[ignore = "spawned only as the platform-stable native listener fixture"]
8316    fn long_lived_native_listener_helper() {
8317        let ready = std::env::var_os("RUNNER_MANAGER_TEST_LISTENER_READY")
8318            .map(PathBuf::from)
8319            .expect("the parent supplies the readiness path");
8320        fs::write(ready, b"ready\n").expect("the listener publishes readiness");
8321        std::thread::sleep(Duration::from_secs(30));
8322    }
8323
8324    fn assert_long_lived_listener_ready(processes: &NativeProcesses, attempt: &RunnerAttempt) {
8325        let ready = attempt.runtime_path().join(TEST_LISTENER_READY);
8326        let deadline = std::time::Instant::now() + Duration::from_secs(5);
8327        loop {
8328            if ready.is_file() {
8329                assert_eq!(fs::read(&ready).unwrap(), b"ready\n");
8330                return;
8331            }
8332            assert!(
8333                processes.is_alive(attempt).unwrap(),
8334                "the native listener exited before publishing readiness"
8335            );
8336            assert!(
8337                std::time::Instant::now() < deadline,
8338                "the native listener stayed alive but never published readiness"
8339            );
8340            std::thread::sleep(Duration::from_millis(10));
8341        }
8342    }
8343
8344    #[tokio::test]
8345    async fn every_production_launch_prunes_under_the_same_allocation_guard() {
8346        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8347        harness.ready().await;
8348        assert_eq!(harness.packages.prunes.load(Ordering::SeqCst), 0);
8349        harness.launch().await;
8350        assert_eq!(harness.packages.prunes.load(Ordering::SeqCst), 1);
8351        assert_eq!(
8352            *harness.packages.prune_currents.lock().unwrap(),
8353            vec![harness.packages.version.clone()],
8354            "the leased current version is an exclusion, never the prune target"
8355        );
8356    }
8357
8358    fn assert_no_jit_file(runtime: &Path) {
8359        for entry in fs::read_dir(runtime).unwrap() {
8360            let path = entry.unwrap().path();
8361            if path.is_file() {
8362                let bytes = fs::read(&path).unwrap();
8363                assert!(
8364                    !bytes
8365                        .windows(JIT.len())
8366                        .any(|window| window == JIT.as_bytes()),
8367                    "a JIT payload survived in a runtime file"
8368                );
8369            }
8370        }
8371    }
8372
8373    #[test]
8374    fn production_listener_command_uses_the_supported_jit_contract() {
8375        let runtime = Path::new("runtime");
8376        let spec = runner_listener_spec(
8377            PathBuf::from("Runner.Listener"),
8378            runtime,
8379            &RunnerEnv::default(),
8380        );
8381        let arguments: Vec<_> = spec
8382            .arguments()
8383            .iter()
8384            .map(|argument| argument.to_string_lossy().into_owned())
8385            .collect();
8386
8387        assert_eq!(arguments, ["run"]);
8388        assert!(
8389            !arguments
8390                .iter()
8391                .any(|argument| argument == "--jit-config-file"),
8392            "the obsolete file option would be rejected by Runner.Listener 2.336.0"
8393        );
8394    }
8395
8396    /// What a production runner starts with: the platform defaults, the host's
8397    /// `runner.env` over them, the attempt's own temporary directory, and
8398    /// normal scheduling.
8399    #[test]
8400    fn production_listener_layers_runner_env_over_the_platform_defaults() {
8401        let root = tempfile::tempdir().unwrap();
8402        let runtime = root.path().join("attempt");
8403        let host_env =
8404            RunnerEnv::parse("ELECTRON_CACHE=/cache/electron\nHOME=/operator/chose/this\n")
8405                .unwrap();
8406        let spec = runner_listener_spec(PathBuf::from("Runner.Listener"), &runtime, &host_env);
8407        let env = |name: &str| spec.configured_env(name).map(PathBuf::from);
8408
8409        assert!(spec.expects_normal_scheduling());
8410        assert_eq!(
8411            env("ELECTRON_CACHE"),
8412            Some(PathBuf::from("/cache/electron"))
8413        );
8414        assert_eq!(
8415            env("HOME"),
8416            Some(PathBuf::from("/operator/chose/this")),
8417            "runner.env must win over a platform default of the same name"
8418        );
8419        for tmp in ["TMPDIR", "TEMP", "TMP"] {
8420            assert_eq!(env(tmp), Some(runtime.join("tmp")), "{tmp}");
8421        }
8422
8423        let home = runtime.join(runner_env::RUNNER_HOME_DIR);
8424        if cfg!(windows) {
8425            // Each runner's own profile, created inside its attempt, so that
8426            // concurrent jobs stop sharing the service account's.
8427            assert_eq!(env("USERPROFILE"), Some(home.clone()));
8428            assert_eq!(env("APPDATA"), Some(home.join(r"AppData\Roaming")));
8429            assert_eq!(env("LOCALAPPDATA"), Some(home.join(r"AppData\Local")));
8430            assert!(home.join(r"AppData\Roaming").is_dir());
8431            assert!(home.join(r"AppData\Local").is_dir());
8432        } else {
8433            // Unix keeps the account's own profile.
8434            assert_eq!(env("USERPROFILE"), None);
8435            assert!(!home.exists());
8436        }
8437    }
8438
8439    #[test]
8440    fn an_invalid_runner_env_fails_the_launch_without_echoing_a_value() {
8441        let root = tempfile::tempdir().unwrap();
8442        let path = runner_env::path_in(root.path());
8443        fs::write(&path, "GOOD=1\nghp_notarealtokenbutshapedlikeone\n").unwrap();
8444        let processes = NativeProcesses::new().with_runner_env_file(path.clone());
8445        let failure = processes.host_env().unwrap_err().to_string();
8446        assert!(
8447            failure.contains("runner.env") && failure.contains("line 2"),
8448            "{failure}"
8449        );
8450        assert!(
8451            !failure.contains("ghp_"),
8452            "the failure echoed a value: {failure}"
8453        );
8454
8455        // `prepare` runs before the JIT registration, so a broken file is
8456        // refused there, before GitHub is asked for anything.
8457        let policy = fixtures::policy()
8458            .repository("octo/repo")
8459            .autoscale("home", 1)
8460            .active()
8461            .build();
8462        let attempt = RunnerAttempt::allocate(
8463            AttemptId::new_random(),
8464            policy.id,
8465            root.path(),
8466            FakeClock::default().now(),
8467        );
8468        assert!(processes.prepare(&attempt, &policy).is_err());
8469
8470        fs::write(&path, "GOOD=1\n").unwrap();
8471        assert!(processes.prepare(&attempt, &policy).is_ok());
8472        assert_eq!(processes.host_env().unwrap().get("GOOD"), Some("1"));
8473        assert!(NativeProcesses::new().host_env().unwrap().is_empty());
8474    }
8475
8476    #[cfg(windows)]
8477    fn native_inspection_spec() -> SpawnSpec {
8478        SpawnSpec::new("powershell.exe").args([
8479            "-NoProfile",
8480            "-NonInteractive",
8481            "-Command",
8482            "Start-Sleep -Seconds 30",
8483        ])
8484    }
8485
8486    #[cfg(unix)]
8487    fn native_inspection_spec() -> SpawnSpec {
8488        SpawnSpec::new("/bin/sh").args(["-c", "sleep 30"])
8489    }
8490
8491    #[cfg(windows)]
8492    fn native_command_line(pid: u32) -> String {
8493        let output = std::process::Command::new("powershell.exe")
8494            .args([
8495                "-NoProfile",
8496                "-NonInteractive",
8497                "-Command",
8498                &format!("(Get-CimInstance Win32_Process -Filter 'ProcessId = {pid}').CommandLine"),
8499            ])
8500            .output()
8501            .expect("PowerShell can inspect the native child");
8502        assert!(output.status.success(), "native process inspection failed");
8503        String::from_utf8(output.stdout).expect("Windows command lines are Unicode")
8504    }
8505
8506    #[cfg(target_os = "linux")]
8507    fn native_command_line(pid: u32) -> String {
8508        fs::read(format!("/proc/{pid}/cmdline"))
8509            .map(|bytes| String::from_utf8_lossy(&bytes).replace('\0', " "))
8510            .expect("/proc exposes the native child command line")
8511    }
8512
8513    #[cfg(target_os = "macos")]
8514    fn native_command_line(pid: u32) -> String {
8515        let output = std::process::Command::new("ps")
8516            .args(["-o", "command=", "-p", &pid.to_string()])
8517            .output()
8518            .expect("ps can inspect the native child");
8519        assert!(output.status.success(), "native process inspection failed");
8520        String::from_utf8(output.stdout).expect("the command line is UTF-8")
8521    }
8522
8523    // -- c2: persistent slot allocation -------------------------------------
8524
8525    #[tokio::test]
8526    async fn a_persistent_repository_leases_s1_and_journals_it_before_any_github_effect() {
8527        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
8528            .with_persistent_workspace(2);
8529        harness.github.watch_journal(Arc::clone(&harness.store));
8530        harness.ready().await;
8531
8532        let attempt = harness.launch().await;
8533
8534        assert_eq!(
8535            attempt.workspace(),
8536            AttemptWorkspace::persistent_slot(nz(1)),
8537            "the lowest free slot is leased"
8538        );
8539        assert_eq!(attempt.runtime_path(), harness.slot_path(1));
8540        assert!(attempt.holds_slot_lease());
8541        // The exact runtime path is journalled, not re-derived later.
8542        assert_eq!(
8543            harness.attempt(attempt.id).runtime_path(),
8544            harness.slot_path(1)
8545        );
8546
8547        // Step 7 of "Slot allocation": the lease exists before GitHub is asked
8548        // for anything, and the runner's work folder stays the relative `_work`
8549        // the slot root is laid out around.
8550        let facts = harness.github.registration_facts();
8551        assert_eq!(facts.len(), 1);
8552        assert_eq!(
8553            facts[0].leased_slots,
8554            vec![1],
8555            "the lease was journalled first"
8556        );
8557        assert_eq!(facts[0].work_folder, DEFAULT_WORK_FOLDER);
8558    }
8559
8560    #[tokio::test]
8561    async fn a_terminal_but_uncleaned_attempt_keeps_its_slot_without_holding_capacity() {
8562        let harness = Harness::new(
8563            FakeGithubLifecycle::default().fail(true),
8564            Arc::new(PersistentDemand),
8565        )
8566        .with_persistent_workspace(2);
8567        harness.ready().await;
8568
8569        // A terminal JIT refusal concludes the attempt without cleaning it.
8570        harness.launch_result().await.unwrap_err();
8571        let first = harness.store.attempts().unwrap().remove(0);
8572        assert_eq!(first.state(), AttemptState::Failed);
8573        assert!(
8574            !first.state().counts_against_capacity(),
8575            "a concluded attempt is invisible to host capacity"
8576        );
8577        assert!(
8578            first.holds_slot_lease(),
8579            "and still owns its directory, so its slot is not free"
8580        );
8581
8582        let second = harness.launch().await;
8583        assert_eq!(second.workspace(), AttemptWorkspace::persistent_slot(nz(2)));
8584        assert_eq!(second.runtime_path(), harness.slot_path(2));
8585        assert_eq!(
8586            harness
8587                .store
8588                .slot_leases_for_policy(harness.policy.id)
8589                .unwrap()
8590                .len(),
8591            2
8592        );
8593    }
8594
8595    #[tokio::test]
8596    async fn two_sequential_allocations_at_capacity_one_reuse_s1_and_its_retained_work() {
8597        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
8598            .with_persistent_workspace(1);
8599        harness.ready().await;
8600
8601        let first = harness.launch().await;
8602        assert_eq!(first.runtime_path(), harness.slot_path(1));
8603
8604        // What a job leaves behind, at the path the runner writes it to.
8605        let checkout = harness.slot_path(1).join(DEFAULT_WORK_FOLDER).join("repo");
8606        fs::create_dir_all(&checkout).unwrap();
8607        fs::write(checkout.join("checkout.txt"), b"from the first job").unwrap();
8608
8609        harness.cleanup_retaining_work(first.id).await;
8610
8611        let second = harness.launch().await;
8612        assert_ne!(second.id, first.id);
8613        assert_eq!(
8614            second.workspace(),
8615            AttemptWorkspace::persistent_slot(nz(1)),
8616            "a released slot is leased again rather than skipped"
8617        );
8618        assert_eq!(
8619            second.runtime_path(),
8620            first.runtime_path(),
8621            "the same slot is the same exact path"
8622        );
8623        assert_eq!(
8624            fs::read_to_string(checkout.join("checkout.txt")).unwrap(),
8625            "from the first job",
8626            "the retained job workspace survived the second allocation"
8627        );
8628        // The attempt's own runner material was recreated for this attempt.
8629        assert!(harness.slot_path(1).join("runner-package").exists());
8630    }
8631
8632    #[tokio::test]
8633    async fn lowering_capacity_leaves_higher_slots_alone_and_raising_it_permits_them_again() {
8634        let mut harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
8635            .with_persistent_workspace(2);
8636        harness.ready().await;
8637
8638        let first = harness.launch().await;
8639        let second = harness.launch().await;
8640        assert_eq!(second.runtime_path(), harness.slot_path(2));
8641        let kept = harness
8642            .slot_path(2)
8643            .join(DEFAULT_WORK_FOLDER)
8644            .join("kept.txt");
8645        fs::create_dir_all(kept.parent().unwrap()).unwrap();
8646        fs::write(&kept, b"s2 was here").unwrap();
8647        harness.cleanup_retaining_work(second.id).await;
8648
8649        // The operator lowers the ceiling while s1 is still leased.
8650        harness.policy.set_max_capacity(nz(1)).unwrap();
8651        let refusal = harness.launch_result().await.unwrap_err().to_string();
8652        assert!(
8653            refusal.contains("s1 to s1"),
8654            "the refusal names the ceiling it reached: {refusal}"
8655        );
8656        assert!(
8657            harness.slot_path(2).exists() && kept.exists(),
8658            "lowering capacity deletes nothing; the higher slot is merely unusable"
8659        );
8660
8661        // Raising it again makes the free higher slot available.
8662        harness.policy.set_max_capacity(nz(2)).unwrap();
8663        let third = harness.launch().await;
8664        assert_eq!(third.workspace(), AttemptWorkspace::persistent_slot(nz(2)));
8665        assert_eq!(third.runtime_path(), harness.slot_path(2));
8666        assert_eq!(fs::read_to_string(&kept).unwrap(), "s2 was here");
8667        assert!(first.holds_slot_lease(), "s1 was never disturbed");
8668    }
8669
8670    #[tokio::test]
8671    async fn organization_and_ephemeral_policies_never_enter_slot_allocation() {
8672        for policy in [
8673            fixtures::policy()
8674                .organization("octo")
8675                .autoscale("home", 2)
8676                .active()
8677                .build(),
8678            fixtures::policy()
8679                .repository("octo/repo")
8680                .autoscale("home", 2)
8681                .active()
8682                .build(),
8683        ] {
8684            let mut harness =
8685                Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
8686                    .with_host_runner_root();
8687            assert_eq!(policy.workspace_policy(), &WorkspacePolicy::Ephemeral);
8688            harness.policy = policy;
8689            harness.ready().await;
8690
8691            let attempt = harness.launch().await;
8692            assert_eq!(attempt.workspace(), AttemptWorkspace::Ephemeral);
8693            assert_eq!(attempt.workspace().slot_number(), None);
8694            assert!(!attempt.holds_slot_lease());
8695            assert_eq!(
8696                attempt.runtime_path().parent().unwrap(),
8697                harness.host_root(),
8698                "a disposable attempt is a child of the host root, never of a slot"
8699            );
8700            assert!(
8701                harness
8702                    .store
8703                    .slot_leases_for_policy(harness.policy.id)
8704                    .unwrap()
8705                    .is_empty()
8706            );
8707        }
8708    }
8709
8710    #[tokio::test]
8711    async fn two_concurrent_allocations_never_share_a_slot() {
8712        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
8713            .with_persistent_workspace(2);
8714        harness.github.watch_journal(Arc::clone(&harness.store));
8715        harness.ready().await;
8716
8717        // Both allocators race for the same host allocation lock, which is what
8718        // orders slot *selection*; each one then journals its lease before it
8719        // asks GitHub for anything.
8720        let (first, second) = tokio::join!(harness.launch_result(), harness.launch_result());
8721        let first = first.unwrap();
8722        let second = second.unwrap();
8723
8724        let slots: BTreeSet<u16> = [&first, &second]
8725            .iter()
8726            .map(|attempt| {
8727                attempt
8728                    .workspace()
8729                    .slot_number()
8730                    .expect("a persistent attempt leases a slot")
8731            })
8732            .collect();
8733        assert_eq!(slots, BTreeSet::from([1, 2]), "one slot each, never shared");
8734        assert_ne!(first.runtime_path(), second.runtime_path());
8735        assert_eq!(
8736            harness
8737                .store
8738                .slot_leases_for_policy(harness.policy.id)
8739                .unwrap()
8740                .len(),
8741            2
8742        );
8743
8744        // Every registration saw *its own* lease already in the journal. Reading
8745        // the whole journal and asking only that it be non-empty would pass on
8746        // the other allocator's lease, which is precisely the ordering bug this
8747        // test exists to exclude.
8748        let facts = harness.github.registration_facts();
8749        assert_eq!(facts.len(), 2);
8750        for fact in facts {
8751            let attempt = [&first, &second]
8752                .into_iter()
8753                .find(|attempt| runner_name(attempt.id) == fact.runner_name)
8754                .expect("every registration belongs to one of the two attempts");
8755            let slot = attempt
8756                .workspace()
8757                .slot_number()
8758                .expect("a persistent attempt leases a slot");
8759            assert!(
8760                fact.leased_slots.contains(&slot),
8761                "a JIT request never precedes its own lease: s{slot} not in {:?}",
8762                fact.leased_slots
8763            );
8764            assert_eq!(fact.work_folder, DEFAULT_WORK_FOLDER);
8765        }
8766    }
8767
8768    #[tokio::test]
8769    async fn the_database_is_the_final_fence_against_two_attempts_in_one_slot() {
8770        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
8771            .with_persistent_workspace(2);
8772        harness.ready().await;
8773        let first = harness.launch().await;
8774
8775        // What a second allocator that lost the race would write: the lock
8776        // orders selection, and this index is what catches a writer the lock
8777        // could not see.
8778        let clash = RunnerAttempt::allocate_in(
8779            AttemptId::new_random(),
8780            harness.policy.id,
8781            first.runtime_path(),
8782            AttemptWorkspace::persistent_slot(nz(1)),
8783            harness.clock.now(),
8784        );
8785        assert!(matches!(
8786            harness.store.record_attempt(&clash).unwrap_err(),
8787            StoreError::SlotAlreadyLeased { slot: 1, .. }
8788        ));
8789
8790        // And the launcher reports it as itself rather than as a generic
8791        // journal failure, so the operator reads what actually happened.
8792        let error = harness.launcher.record_allocation(&clash).unwrap_err();
8793        let rendered = error.to_string();
8794        assert!(rendered.contains("slot s1"), "{rendered}");
8795        assert!(rendered.contains("nothing was written"), "{rendered}");
8796        assert_eq!(
8797            harness.store.attempts().unwrap().len(),
8798            1,
8799            "the losing allocator journalled nothing"
8800        );
8801    }
8802
8803    #[test]
8804    fn slot_selection_fills_the_lowest_gap_and_stops_at_the_ceiling() {
8805        let leased = |slots: &[u16]| -> Vec<RunnerAttempt> {
8806            slots
8807                .iter()
8808                .map(|slot| {
8809                    RunnerAttempt::allocate_in(
8810                        AttemptId::new_random(),
8811                        fixtures::POLICY_ID,
8812                        format!("/srv/rman/acme/s{slot}"),
8813                        AttemptWorkspace::persistent_slot(nz(*slot)),
8814                        fixtures::created_at(),
8815                    )
8816                })
8817                .collect()
8818        };
8819
8820        assert_eq!(lowest_free_slot(&[], nz(1)), Some(nz(1)));
8821        assert_eq!(lowest_free_slot(&leased(&[1]), nz(4)), Some(nz(2)));
8822        // The gap a released middle slot leaves is filled before the tail.
8823        assert_eq!(lowest_free_slot(&leased(&[1, 3]), nz(4)), Some(nz(2)));
8824        // The ceiling is a refusal, never a reason to allocate past it.
8825        assert_eq!(lowest_free_slot(&leased(&[1]), nz(1)), None);
8826        assert_eq!(lowest_free_slot(&leased(&[1, 2]), nz(2)), None);
8827        // An ephemeral attempt holds no slot and cannot block one.
8828        let ephemeral = vec![RunnerAttempt::allocate(
8829            AttemptId::new_random(),
8830            fixtures::POLICY_ID,
8831            "/srv/rman/host/abc",
8832            fixtures::created_at(),
8833        )];
8834        assert_eq!(lowest_free_slot(&ephemeral, nz(1)), Some(nz(1)));
8835    }
8836
8837    #[test]
8838    fn a_slot_is_reusable_only_when_it_is_empty_or_holds_one_real_work_directory() {
8839        let root = tempfile::tempdir().unwrap();
8840        let slot = root.path().join("s1");
8841        fs::create_dir(&slot).unwrap();
8842        accept_reusable_slot(&slot).expect("an empty slot is reusable");
8843
8844        fs::create_dir(slot.join(DEFAULT_WORK_FOLDER)).unwrap();
8845        accept_reusable_slot(&slot).expect("a retained job workspace is reusable");
8846
8847        // Runner material a previous attempt left behind is refused rather than
8848        // reused or removed: deciding those bytes are safe is cleanup's job.
8849        fs::create_dir(slot.join("bin")).unwrap();
8850        fs::write(slot.join(".github-runner-id"), b"73").unwrap();
8851        let refusal = accept_reusable_slot(&slot).unwrap_err().to_string();
8852        assert!(refusal.contains("bin"), "{refusal}");
8853        assert!(refusal.contains(".github-runner-id"), "{refusal}");
8854
8855        // A `_work` that is not a real directory is not a job workspace.
8856        let file_work = root.path().join("s2");
8857        fs::create_dir(&file_work).unwrap();
8858        fs::write(file_work.join(DEFAULT_WORK_FOLDER), b"not a directory").unwrap();
8859        assert!(accept_reusable_slot(&file_work).is_err());
8860    }
8861
8862    #[cfg(unix)]
8863    #[test]
8864    fn a_link_shaped_work_directory_is_refused_rather_than_followed() {
8865        // Windows needs a privilege to create either kind of link, so the
8866        // link-shaped cases are asserted here; the rule itself is
8867        // platform-independent because it is `symlink_metadata`'s answer.
8868        let root = tempfile::tempdir().unwrap();
8869        let elsewhere = root.path().join("elsewhere");
8870        fs::create_dir(&elsewhere).unwrap();
8871
8872        let slot = root.path().join("s1");
8873        fs::create_dir(&slot).unwrap();
8874        std::os::unix::fs::symlink(&elsewhere, slot.join(DEFAULT_WORK_FOLDER)).unwrap();
8875        assert!(accept_reusable_slot(&slot).is_err());
8876
8877        let linked_slot = root.path().join("s2");
8878        std::os::unix::fs::symlink(&elsewhere, &linked_slot).unwrap();
8879        assert!(create_or_validate_slot(&linked_slot).is_err());
8880    }
8881
8882    #[test]
8883    fn a_slot_standing_where_a_file_is_refuses_rather_than_replacing_it() {
8884        let root = tempfile::tempdir().unwrap();
8885        let occupied = root.path().join("s1");
8886        fs::write(&occupied, b"an operator's file").unwrap();
8887        let refusal = create_or_validate_slot(&occupied).unwrap_err().to_string();
8888        assert!(refusal.contains("is not a directory"), "{refusal}");
8889        assert_eq!(fs::read_to_string(&occupied).unwrap(), "an operator's file");
8890
8891        let fresh = root.path().join("s2");
8892        create_or_validate_slot(&fresh).expect("a missing slot is created");
8893        assert!(fresh.is_dir());
8894        create_or_validate_slot(&fresh).expect("an existing directory is accepted");
8895    }
8896
8897    #[test]
8898    fn the_retained_work_directory_is_matched_the_way_the_filesystem_matches_it() {
8899        assert!(is_work_folder(OsStr::new(DEFAULT_WORK_FOLDER)));
8900        assert!(!is_work_folder(OsStr::new("_work2")));
8901        // A Windows filesystem is case-insensitive, so `_Work` *is* the retained
8902        // job workspace there and must never be removed as a leftover; on a
8903        // case-sensitive filesystem it is a different directory entirely.
8904        assert_eq!(is_work_folder(OsStr::new("_Work")), cfg!(windows));
8905    }
8906
8907    #[test]
8908    fn package_materialization_never_overwrites_or_follows_a_retained_work_directory() {
8909        let root = tempfile::tempdir().unwrap();
8910        let package = root.path().join("package");
8911        fs::create_dir_all(package.join("bin")).unwrap();
8912        fs::write(package.join("bin").join("Runner.Listener"), b"binary").unwrap();
8913        // A nested `_work` inside the package's own tree is an ordinary name.
8914        fs::create_dir_all(package.join("externals").join(DEFAULT_WORK_FOLDER)).unwrap();
8915
8916        let slot = root.path().join("s1");
8917        let retained = slot.join(DEFAULT_WORK_FOLDER).join("repo");
8918        fs::create_dir_all(&retained).unwrap();
8919        fs::write(retained.join("checkout.txt"), b"from the first job").unwrap();
8920
8921        copy_package_tree(&package, &slot, &TrustedTrees::default())
8922            .expect("the package lays out around `_work`");
8923        assert!(slot.join("bin").join("Runner.Listener").exists());
8924        assert!(
8925            slot.join("externals").join(DEFAULT_WORK_FOLDER).is_dir(),
8926            "the guard is top-level only"
8927        );
8928        assert_eq!(
8929            fs::read_to_string(retained.join("checkout.txt")).unwrap(),
8930            "from the first job"
8931        );
8932
8933        // A package that ever grew a top-level `_work` is refused, not merged.
8934        fs::create_dir(package.join(DEFAULT_WORK_FOLDER)).unwrap();
8935        let error = copy_package_tree(&package, &slot, &TrustedTrees::default()).unwrap_err();
8936        assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
8937        assert_eq!(
8938            fs::read_to_string(retained.join("checkout.txt")).unwrap(),
8939            "from the first job"
8940        );
8941    }
8942
8943    #[test]
8944    fn rolling_back_a_materialization_keeps_a_slot_but_removes_a_disposable_directory() {
8945        let root = tempfile::tempdir().unwrap();
8946
8947        let slot = root.path().join("s1");
8948        let retained = slot.join(DEFAULT_WORK_FOLDER);
8949        fs::create_dir_all(retained.join("repo")).unwrap();
8950        fs::write(retained.join("repo").join("checkout.txt"), b"kept").unwrap();
8951        fs::create_dir_all(slot.join("bin")).unwrap();
8952        fs::write(slot.join(".github-runner-id"), b"73").unwrap();
8953        let persistent = RunnerAttempt::allocate_in(
8954            AttemptId::new_random(),
8955            fixtures::POLICY_ID,
8956            &slot,
8957            AttemptWorkspace::persistent_slot(nz(1)),
8958            fixtures::created_at(),
8959        );
8960
8961        remove_materialized_package(&persistent).unwrap();
8962        assert!(slot.is_dir(), "the slot itself is not removed");
8963        assert!(!slot.join("bin").exists());
8964        assert!(!slot.join(".github-runner-id").exists());
8965        assert_eq!(
8966            fs::read_to_string(retained.join("repo").join("checkout.txt")).unwrap(),
8967            "kept"
8968        );
8969
8970        let disposable_path = root.path().join("abcdef012345");
8971        fs::create_dir_all(disposable_path.join(DEFAULT_WORK_FOLDER)).unwrap();
8972        let disposable = RunnerAttempt::allocate(
8973            AttemptId::new_random(),
8974            fixtures::POLICY_ID,
8975            &disposable_path,
8976            fixtures::created_at(),
8977        );
8978        remove_materialized_package(&disposable).unwrap();
8979        assert!(
8980            !disposable_path.exists(),
8981            "a disposable directory is still removed whole"
8982        );
8983    }
8984
8985    // -- c3: persistent cleanup and recovery --------------------------------
8986
8987    /// The runner state one attempt leaves at a slot root, as a real attempt
8988    /// leaves it: binaries, registration identity, a JIT handoff that outlived
8989    /// its process, and this agent's own lifecycle sidecars.
8990    ///
8991    /// Driven by [`SENSITIVE_SLOT_ENTRIES`] rather than by a second copy of it,
8992    /// so a name added to the thing cleanup must prove absent is a name every
8993    /// test here starts leaving behind.
8994    fn litter_the_slot(slot: &Path) {
8995        for directory in ["bin", "externals", "_diag"] {
8996            fs::create_dir_all(slot.join(directory)).unwrap();
8997        }
8998        fs::write(slot.join("bin").join("Runner.Listener"), b"binary").unwrap();
8999        for file in SENSITIVE_SLOT_ENTRIES
9000            .iter()
9001            .filter(|entry| !slot.join(entry).is_dir())
9002        {
9003            fs::write(slot.join(file), b"runner state").unwrap();
9004        }
9005        // A handoff whose owning process died before `Drop` could delete it.
9006        fs::write(
9007            slot.join(format!(
9008                "{}0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0.tmp",
9009                RestrictiveHandoff::NAME_PREFIX
9010            )),
9011            JIT.as_bytes(),
9012        )
9013        .unwrap();
9014    }
9015
9016    /// A marker under `_work` of the kind a job leaves for the next one.
9017    fn retain_under_work(slot: &Path) -> PathBuf {
9018        let checkout = slot.join(DEFAULT_WORK_FOLDER).join("repo").join("target");
9019        fs::create_dir_all(&checkout).unwrap();
9020        let marker = checkout.join("build-output.bin");
9021        fs::write(&marker, RETAINED).unwrap();
9022        marker
9023    }
9024
9025    /// What a job leaves under `_work` for the next job to reuse.
9026    const RETAINED: &str = "a Git-ignored build output the next job reuses";
9027
9028    /// Every direct entry of a directory, sorted, as plain strings.
9029    fn entries_of(directory: &Path) -> Vec<String> {
9030        let mut names: Vec<String> = fs::read_dir(directory)
9031            .unwrap()
9032            .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
9033            .collect();
9034        names.sort();
9035        names
9036    }
9037
9038    /// The one entry a cleaned slot is allowed to hold.
9039    fn only_the_job_workspace() -> Vec<String> {
9040        vec![DEFAULT_WORK_FOLDER.to_owned()]
9041    }
9042
9043    #[cfg(unix)]
9044    #[test]
9045    fn disposable_tree_removal_does_not_open_a_dotnet_diagnostic_fifo() {
9046        use std::sync::mpsc;
9047
9048        let temporary = tempfile::tempdir().unwrap();
9049        let tree = temporary.path().join("attempt");
9050        let diagnostic = tree.join("tmp/clr-debug-pipe-runner-in");
9051        fs::create_dir_all(diagnostic.parent().unwrap()).unwrap();
9052        assert!(
9053            std::process::Command::new("mkfifo")
9054                .arg(&diagnostic)
9055                .status()
9056                .unwrap()
9057                .success()
9058        );
9059
9060        let (finished, result) = mpsc::channel();
9061        std::thread::spawn(move || {
9062            let removed = remove_runtime_tree(&tree);
9063            let _ = finished.send(removed);
9064        });
9065
9066        result
9067            .recv_timeout(Duration::from_secs(2))
9068            .expect("runtime deletion must not wait for a FIFO peer")
9069            .unwrap();
9070        assert!(!diagnostic.exists());
9071    }
9072
9073    /// The junction WinINet leaves in a job's profile, reproduced: its own DACL
9074    /// denies `Everyone` the right to list it.
9075    #[cfg(windows)]
9076    #[test]
9077    fn disposable_tree_removal_unlinks_a_junction_nobody_may_list() {
9078        let temporary = tempfile::tempdir().unwrap();
9079        let tree = temporary.path().join("attempt");
9080        let cache = tree.join(r"tmp\home\AppData\Local\Microsoft\Windows\INetCache");
9081        let outside = temporary.path().join("outside");
9082        fs::create_dir_all(&cache).unwrap();
9083        fs::create_dir_all(&outside).unwrap();
9084        fs::write(outside.join("keep.txt"), b"not the attempt's").unwrap();
9085        fs::write(tree.join("tmp").join("job.log"), b"job output").unwrap();
9086        let junction = cache.join("Content.IE5");
9087        let created = std::process::Command::new("cmd")
9088            .arg("/C")
9089            .arg("mklink")
9090            .arg("/J")
9091            .arg(&junction)
9092            .arg(&outside)
9093            .output()
9094            .unwrap();
9095        assert!(created.status.success(), "{created:?}");
9096        // `/L` puts the entry on the junction itself, as WinINet does, rather
9097        // than on the directory it points at. `*S-1-1-0` is `Everyone` in every
9098        // display language.
9099        let denied = std::process::Command::new("icacls")
9100            .arg(&junction)
9101            .args(["/L", "/deny", "*S-1-1-0:(RD)"])
9102            .output()
9103            .unwrap();
9104        assert!(denied.status.success(), "{denied:?}");
9105
9106        // The control: without the fix the tree cannot be removed at all.
9107        let unfixed = remove_dir_all::remove_dir_all(&tree)
9108            .expect_err("the fixture no longer reproduces the WinINet junction");
9109        assert_eq!(unfixed.kind(), std::io::ErrorKind::PermissionDenied);
9110        assert!(junction.symlink_metadata().is_ok());
9111
9112        remove_runtime_tree(&tree).expect("a tree holding a deny-list junction is removed");
9113        assert!(!tree.exists(), "the attempt tree survived");
9114        assert_eq!(
9115            fs::read(outside.join("keep.txt")).unwrap(),
9116            b"not the attempt's",
9117            "removing the junction followed it into the directory it points at"
9118        );
9119    }
9120
9121    /// One slot entry that refuses to be removed, and the undo that lets the
9122    /// temporary directory be torn down afterwards.
9123    ///
9124    /// The two operating systems refuse for different reasons and there is no
9125    /// portable third. Windows will not open a file for deletion while a handle
9126    /// with share mode zero is held on it; Unix will not unlink from a directory
9127    /// the caller cannot write. Both are states a real machine reaches -- a
9128    /// scanner holding a file open, a job that left a directory read-only -- so
9129    /// the injection is a filesystem fact rather than a seam cut into the
9130    /// product for a test to pull.
9131    ///
9132    /// The Unix half is a permission, and permissions do not apply to `root`.
9133    /// [`Self::inject`] proves the block on a throwaway directory before
9134    /// claiming it, so a suite running as `root` says it could not inject rather
9135    /// than asserting nothing and passing.
9136    struct BlockedDeletion {
9137        directory: PathBuf,
9138        #[cfg(windows)]
9139        _handle: fs::File,
9140    }
9141
9142    impl BlockedDeletion {
9143        const HELD: &'static str = "held-open";
9144
9145        /// Fill `directory` with a file that cannot be removed, or answer `None`
9146        /// when this account cannot be stopped from removing anything.
9147        fn inject(directory: &Path) -> Option<Self> {
9148            #[cfg(unix)]
9149            if !Self::refusal_is_possible() {
9150                return None;
9151            }
9152            fs::create_dir_all(directory).unwrap();
9153            fs::write(
9154                directory.join(Self::HELD),
9155                b"a file the scrub cannot remove",
9156            )
9157            .unwrap();
9158            #[cfg(windows)]
9159            let handle = {
9160                use std::os::windows::fs::OpenOptionsExt;
9161
9162                fs::OpenOptions::new()
9163                    .read(true)
9164                    .share_mode(0)
9165                    .open(directory.join(Self::HELD))
9166                    .expect("the blocking handle opens")
9167            };
9168            #[cfg(unix)]
9169            Self::set_mode(directory, 0o555);
9170            Some(Self {
9171                directory: directory.to_path_buf(),
9172                #[cfg(windows)]
9173                _handle: handle,
9174            })
9175        }
9176
9177        fn release(self) {
9178            drop(self);
9179        }
9180
9181        #[cfg(unix)]
9182        fn refusal_is_possible() -> bool {
9183            let probe = tempfile::tempdir().unwrap();
9184            let directory = probe.path().join("probe");
9185            fs::create_dir(&directory).unwrap();
9186            fs::write(directory.join("file"), b"probe").unwrap();
9187            Self::set_mode(&directory, 0o555);
9188            let refused = fs::remove_dir_all(&directory).is_err();
9189            Self::set_mode(&directory, 0o755);
9190            refused
9191        }
9192
9193        #[cfg(unix)]
9194        fn set_mode(directory: &Path, mode: u32) {
9195            use std::os::unix::fs::PermissionsExt;
9196
9197            let mut permissions = fs::metadata(directory).unwrap().permissions();
9198            permissions.set_mode(mode);
9199            fs::set_permissions(directory, permissions).unwrap();
9200        }
9201    }
9202
9203    impl Drop for BlockedDeletion {
9204        fn drop(&mut self) {
9205            #[cfg(unix)]
9206            Self::set_mode(&self.directory, 0o755);
9207            #[cfg(not(unix))]
9208            let _ = &self.directory;
9209        }
9210    }
9211
9212    #[tokio::test]
9213    async fn two_sequential_jobs_keep_the_checkout_and_start_without_the_earlier_runner_state() {
9214        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
9215            .with_persistent_workspace(1);
9216        harness.ready().await;
9217
9218        let first = harness.launch().await;
9219        let slot = harness.slot_path(1);
9220        assert_eq!(first.runtime_path(), slot);
9221        assert_eq!(
9222            read_runner_id(&slot),
9223            Some(73),
9224            "the attempt registered, so its identity is on disk"
9225        );
9226        let marker = retain_under_work(&slot);
9227        litter_the_slot(&slot);
9228
9229        harness.cleanup_retaining_work(first.id).await;
9230
9231        // The allowlist is exactly one entry, so this assertion is the security
9232        // property in full: what is retained, and that nothing else is.
9233        assert_eq!(entries_of(&slot), only_the_job_workspace());
9234        assert_eq!(fs::read_to_string(&marker).unwrap(), RETAINED);
9235        assert_eq!(
9236            read_runner_id(&slot),
9237            None,
9238            "the first attempt's registration identity is gone before the second starts"
9239        );
9240        assert_eq!(harness.attempt(first.id).state(), AttemptState::Cleaned);
9241        assert!(!harness.attempt(first.id).holds_slot_lease());
9242
9243        let second = harness.launch().await;
9244        assert_ne!(second.id, first.id);
9245        assert_eq!(second.workspace(), AttemptWorkspace::persistent_slot(nz(1)));
9246        assert_eq!(
9247            second.runtime_path(),
9248            slot,
9249            "the same slot, so the same retained `_work`"
9250        );
9251        assert_eq!(fs::read_to_string(&marker).unwrap(), RETAINED);
9252    }
9253
9254    #[tokio::test]
9255    async fn cleaning_a_persistent_slot_needs_no_policy_and_scans_no_directory_for_ownership() {
9256        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
9257            .with_persistent_workspace(1);
9258        harness.ready().await;
9259        let attempt = harness.launch().await;
9260        let slot = harness.slot_path(1);
9261        let marker = retain_under_work(&slot);
9262        litter_the_slot(&slot);
9263        harness.conclude(attempt.id);
9264
9265        // A repository removed from the product between the attempt concluding
9266        // and the sweep reaching it. The journalled runtime path and slot are
9267        // the only facts left, and `04-security-recovery.md` requires them to be
9268        // enough: the alternative is scanning a root to work out which
9269        // directories were ours, which invariant 6 forbids.
9270        harness
9271            .store
9272            .remove_policy(harness.policy.id, harness.policy.revision())
9273            .unwrap();
9274        assert!(harness.store.policy(harness.policy.id).unwrap().is_none());
9275
9276        harness
9277            .launcher
9278            .clean(attempt.id)
9279            .await
9280            .expect("journal facts alone are enough to clean the slot");
9281
9282        assert_eq!(entries_of(&slot), only_the_job_workspace());
9283        assert!(marker.exists());
9284        assert_eq!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
9285    }
9286
9287    #[tokio::test]
9288    async fn an_injected_partial_deletion_quarantines_the_slot_across_a_restart() {
9289        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
9290            .with_persistent_workspace(2);
9291        harness.ready().await;
9292        let first = harness.launch().await;
9293        let slot = harness.slot_path(1);
9294        let marker = retain_under_work(&slot);
9295        litter_the_slot(&slot);
9296        harness.conclude(first.id);
9297
9298        let Some(block) = BlockedDeletion::inject(&slot.join("bin")) else {
9299            eprintln!(
9300                "skipped: this account cannot be refused a deletion, so no partial deletion can \
9301                 be injected"
9302            );
9303            return;
9304        };
9305
9306        let refusal = harness
9307            .launcher
9308            .clean(first.id)
9309            .await
9310            .expect_err("a deletion that failed may not report a cleaned slot");
9311        let rendered = refusal.reason.to_string();
9312        assert!(rendered.contains("could not be removed"), "{rendered}");
9313
9314        let held = harness.attempt(first.id);
9315        assert_eq!(held.state(), AttemptState::Failed, "still not cleaned");
9316        assert!(held.holds_slot_lease(), "so the slot is still leased");
9317        assert!(
9318            !held.state().counts_against_capacity(),
9319            "and a concluded attempt still costs the host no capacity"
9320        );
9321
9322        // The same journal and the same directories, under a launcher that
9323        // remembers nothing. Recovery must complete: a host that can launch
9324        // nothing at all because one slot is stuck is not what "does not count
9325        // as active host capacity" means.
9326        let restarted = harness.restart();
9327        restarted
9328            .recover_startup(std::slice::from_ref(&harness.policy))
9329            .await
9330            .expect("one quarantined slot does not stop the host recovering");
9331        assert_eq!(
9332            harness.attempt(first.id).state(),
9333            AttemptState::Failed,
9334            "the quarantine survived the restart"
9335        );
9336        assert!(
9337            harness
9338                .reconcile_events
9339                .events()
9340                .iter()
9341                .any(|event| matches!(
9342                    event,
9343                    LifecycleEvent::AttemptCleanFailed {
9344                        reason: "slot_entry_could_not_be_removed",
9345                        ..
9346                    }
9347                )),
9348            "the refusal is reported rather than retried in silence"
9349        );
9350
9351        // Capacity two, slot one quarantined: the next attempt goes to s2 and
9352        // never to the slot still holding runner state.
9353        let guard = harness.allocation_lock.acquire().await.unwrap();
9354        let second = restarted
9355            .launch(LaunchRequest {
9356                host: &harness.host,
9357                policy: &harness.policy,
9358                allocation_guard: &guard,
9359                lock_wait: Duration::ZERO,
9360            })
9361            .await
9362            .expect("the host can still launch");
9363        assert_eq!(second.workspace(), AttemptWorkspace::persistent_slot(nz(2)));
9364        drop(guard);
9365
9366        // And the same cleanup succeeds once the obstruction is gone, which is
9367        // what "retry through normal recovery" has to mean.
9368        block.release();
9369        restarted
9370            .clean(first.id)
9371            .await
9372            .expect("the retried cleanup completes");
9373        assert_eq!(entries_of(&slot), only_the_job_workspace());
9374        assert!(marker.exists());
9375        assert_eq!(harness.attempt(first.id).state(), AttemptState::Cleaned);
9376    }
9377
9378    /// The disposable half of the same injection, which had no test of its own.
9379    ///
9380    /// `scrub_workspace`'s ephemeral arm turns a failed `remove_dir_all` into
9381    /// `"attempt workspace could not be removed"`, and until now that branch was
9382    /// only reachable in theory: every injected-deletion test drove a persistent
9383    /// slot. The property is the same one and matters for the same reason --
9384    /// `04-security-recovery.md`'s "Cleanup partly fails and the slot is reused
9385    /// anyway" -- but the disposable guarantee is stronger, so a cleanup that
9386    /// reported success over a directory it had not removed would be the
9387    /// contamination gate failing silently rather than a slot being held.
9388    #[tokio::test]
9389    async fn an_injected_deletion_failure_leaves_a_disposable_attempt_uncleaned() {
9390        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
9391        harness.ready().await;
9392        let attempt = harness.launch().await;
9393        let runtime = attempt.runtime_path().to_path_buf();
9394        assert_eq!(attempt.workspace(), AttemptWorkspace::Ephemeral);
9395        harness.conclude(attempt.id);
9396
9397        let Some(block) = BlockedDeletion::inject(&runtime.join("held-open-subdirectory")) else {
9398            eprintln!(
9399                "skipped: this account cannot be refused a deletion, so no partial deletion can be injected"
9400            );
9401            return;
9402        };
9403
9404        let refusal = harness
9405            .launcher
9406            .clean(attempt.id)
9407            .await
9408            .expect_err("a deletion that failed may not report a removed workspace");
9409        let rendered = refusal.reason.to_string();
9410        assert!(
9411            rendered.contains("could not be removed"),
9412            "the refusal names what happened: {rendered}"
9413        );
9414        assert_ne!(
9415            harness.attempt(attempt.id).state(),
9416            AttemptState::Cleaned,
9417            "an attempt whose directory is still on disk is not cleaned"
9418        );
9419        assert!(
9420            runtime.is_dir(),
9421            "the directory the removal could not finish is still there, which is the fact the journal must keep agreeing with"
9422        );
9423
9424        let restarted = harness.restart();
9425        restarted
9426            .recover_startup(std::slice::from_ref(&harness.policy))
9427            .await
9428            .expect("one locked ephemeral workspace does not stop startup recovery");
9429        assert!(
9430            harness
9431                .reconcile_events
9432                .events()
9433                .iter()
9434                .any(|event| matches!(
9435                    event,
9436                    LifecycleEvent::AttemptCleanFailed {
9437                        attempt: failed_attempt,
9438                        reason: "ephemeral_workspace_could_not_be_removed",
9439                        ..
9440                    } if *failed_attempt == attempt.id
9441                )),
9442            "the deferred cleanup remains visible while the daemon keeps running"
9443        );
9444
9445        // And the ordinary retry -- the reconciler's next terminal sweep --
9446        // finishes it once the obstruction is gone.
9447        block.release();
9448        harness
9449            .launcher
9450            .clean(attempt.id)
9451            .await
9452            .expect("the retried cleanup completes");
9453        assert!(!runtime.exists(), "the whole attempt directory goes");
9454        assert_eq!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
9455    }
9456
9457    /// Supervision retries a workspace it cannot remove with per-attempt
9458    /// backoff, so a stuck workspace is reported once per delay rather than on
9459    /// every pass.
9460    #[tokio::test]
9461    async fn supervision_backs_off_a_workspace_it_cannot_remove() {
9462        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
9463        harness.ready().await;
9464        let attempt = harness.launch().await;
9465        let runtime = attempt.runtime_path().to_path_buf();
9466        harness.conclude(attempt.id);
9467        let Some(block) = BlockedDeletion::inject(&runtime.join("held-open-subdirectory")) else {
9468            eprintln!(
9469                "skipped: this account cannot be refused a deletion, so no partial deletion can be injected"
9470            );
9471            return;
9472        };
9473        let failures = || {
9474            harness
9475                .reconcile_events
9476                .events()
9477                .iter()
9478                .filter(|event| {
9479                    matches!(
9480                        event,
9481                        LifecycleEvent::AttemptCleanFailed { attempt: failed, .. }
9482                            if *failed == attempt.id
9483                    )
9484                })
9485                .count()
9486        };
9487        let supervise = async || {
9488            harness
9489                .launcher
9490                .supervise(&harness.policy)
9491                .await
9492                .expect("a stuck workspace does not fail supervision");
9493        };
9494
9495        supervise().await;
9496        assert_eq!(failures(), 1);
9497        supervise().await;
9498        assert_eq!(
9499            failures(),
9500            1,
9501            "the failed cleanup was retried on the very next pass"
9502        );
9503        harness.clock.advance_secs(31);
9504        supervise().await;
9505        assert_eq!(
9506            failures(),
9507            2,
9508            "the cleanup was not retried once its delay passed"
9509        );
9510
9511        block.release();
9512        harness.clock.advance_secs(61);
9513        supervise().await;
9514        assert!(
9515            !runtime.exists(),
9516            "the retry removes the workspace once it can"
9517        );
9518        assert_eq!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
9519    }
9520
9521    #[tokio::test]
9522    async fn changing_a_repository_back_to_ephemeral_leaves_every_old_slot_untouched() {
9523        let mut harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
9524            .with_persistent_workspace(1);
9525        harness.ready().await;
9526        let first = harness.launch().await;
9527        let slot = harness.slot_path(1);
9528        let marker = retain_under_work(&slot);
9529        harness.cleanup_retaining_work(first.id).await;
9530
9531        // Every attempt for this policy is cleaned, so the mutation is allowed
9532        // (`04-security-recovery.md`, "Recovery rules"). What it must not do is
9533        // move or delete anything the operator still owns.
9534        harness
9535            .policy
9536            .set_workspace_policy(WorkspacePolicy::Ephemeral)
9537            .unwrap();
9538
9539        let second = harness.launch().await;
9540        assert_eq!(second.workspace(), AttemptWorkspace::Ephemeral);
9541        assert_eq!(
9542            second.runtime_path().parent().unwrap(),
9543            harness.host_root(),
9544            "a disposable attempt is a child of the host root"
9545        );
9546        assert!(slot.is_dir(), "the old slot is left where it stands");
9547        assert_eq!(fs::read_to_string(&marker).unwrap(), RETAINED);
9548
9549        // And cleaning the disposable attempt removes its own directory whole
9550        // without reaching the retained slot beside it.
9551        harness.conclude(second.id);
9552        harness.launcher.clean(second.id).await.unwrap();
9553        assert!(!second.runtime_path().exists());
9554        assert!(marker.exists());
9555    }
9556
9557    #[tokio::test]
9558    async fn a_persistent_slot_is_scrubbed_only_after_the_process_is_signalled_and_gone() {
9559        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
9560            .with_persistent_workspace(1);
9561        harness.ready().await;
9562        let slot = harness.slot_path(1);
9563        fs::create_dir_all(&slot).unwrap();
9564        let marker = retain_under_work(&slot);
9565        litter_the_slot(&slot);
9566
9567        let id = AttemptId::new_random();
9568        let mut attempt = RunnerAttempt::allocate_in(
9569            id,
9570            harness.policy.id,
9571            &slot,
9572            AttemptWorkspace::persistent_slot(nz(1)),
9573            harness.clock.now(),
9574        );
9575        attempt.jit_received(harness.clock.now()).unwrap();
9576        attempt.started(4242, harness.clock.now()).unwrap();
9577        harness.store.record_attempt(&attempt).unwrap();
9578        harness.clock.advance_secs(11);
9579        harness.processes.set_alive(true);
9580        harness
9581            .github
9582            .observe(GithubRunnerObservation::NotRegistered);
9583
9584        harness.launcher.supervise(&harness.policy).await.unwrap();
9585
9586        // The identity and termination ordering `e3` established is unchanged by
9587        // the workspace kind: the intent is durable before the signal, and the
9588        // slot is scrubbed only once the process is gone.
9589        let actions = harness.processes.actions.lock().unwrap().clone();
9590        let intent = actions
9591            .iter()
9592            .position(|action| *action == "terminate_intent")
9593            .unwrap();
9594        let signal = actions
9595            .iter()
9596            .position(|action| *action == "terminate")
9597            .unwrap();
9598        assert!(intent < signal, "{actions:?}");
9599        assert!(!harness.processes.alive.load(Ordering::SeqCst));
9600
9601        let cleaned = harness.attempt(id);
9602        assert_eq!(cleaned.state(), AttemptState::Cleaned);
9603        assert!(matches!(
9604            cleaned.outcome(),
9605            Some(AttemptOutcome::Failed {
9606                reason: FailureReason::TerminatedAfterRegistrationTimeout
9607            })
9608        ));
9609        assert_eq!(entries_of(&slot), only_the_job_workspace());
9610        assert!(marker.exists());
9611    }
9612
9613    #[test]
9614    fn a_scrub_retains_one_real_work_directory_and_removes_every_other_entry() {
9615        let root = tempfile::tempdir().unwrap();
9616        let slot = root.path().join("s1");
9617        fs::create_dir(&slot).unwrap();
9618        let marker = retain_under_work(&slot);
9619        litter_the_slot(&slot);
9620        fs::write(slot.join("runner-package"), b"verified").unwrap();
9621
9622        scrub_slot_entries(&slot).expect("a slot of ordinary runner state scrubs");
9623        verify_slot_scrubbed(&slot).expect("and proves it afterwards");
9624
9625        assert_eq!(entries_of(&slot), only_the_job_workspace());
9626        assert!(marker.exists());
9627    }
9628
9629    #[test]
9630    fn a_residue_refusal_never_reports_the_under_count_as_the_fact() {
9631        let slot = Path::new("/runners/s1");
9632
9633        // The ordinary case: the listing counted, so the count is the fact and
9634        // the published names qualify it.
9635        let counted = residue_detail(slot, 2, &["`bin`".to_owned()]);
9636        assert!(counted.contains("2 entries other than"), "{counted}");
9637        assert!(counted.contains("including `bin`"), "{counted}");
9638        assert_eq!(
9639            residue_detail(slot, 1, &[]),
9640            format!(
9641                "1 entry other than `{DEFAULT_WORK_FOLDER}` survived cleanup of {}",
9642                slot.display()
9643            )
9644        );
9645
9646        // The race the second pass exists for: the listing saw nothing and the
9647        // filesystem answered otherwise. Saying "0 entries survived" here would
9648        // state the under-count as the fact and contradict the rest of the
9649        // sentence.
9650        let raced = residue_detail(slot, 0, &["`.credentials`".to_owned()]);
9651        assert!(!raced.contains('0'), "{raced}");
9652        assert!(raced.contains("reported nothing but"), "{raced}");
9653        assert!(raced.contains("`.credentials` survived cleanup"), "{raced}");
9654    }
9655
9656    #[test]
9657    fn verification_asks_the_filesystem_rather_than_the_listing_that_missed_an_entry() {
9658        let root = tempfile::tempdir().unwrap();
9659        let slot = root.path().join("s1");
9660        fs::create_dir(&slot).unwrap();
9661        fs::create_dir(slot.join(DEFAULT_WORK_FOLDER)).unwrap();
9662        verify_slot_scrubbed(&slot).expect("only `_work` is a clean slot");
9663
9664        // Runner binaries, registration identity, process identity and this
9665        // agent's lifecycle marks, one at a time, so a scrub that skipped
9666        // exactly one is still caught.
9667        for survivor in ["bin", ".credentials", IDENTITY_FILE, RUNNER_ID_FILE] {
9668            fs::write(slot.join(survivor), b"left behind").unwrap();
9669            let quarantine = verify_slot_scrubbed(&slot).unwrap_err();
9670            assert_eq!(quarantine.refusal, SlotRefusal::Residue);
9671            assert!(
9672                quarantine.detail.contains(&format!("`{survivor}`")),
9673                "{quarantine}"
9674            );
9675            fs::remove_file(slot.join(survivor)).unwrap();
9676        }
9677
9678        // A handoff is named by its published prefix, never by the UUID that
9679        // follows it, and never by the payload it holds.
9680        let handoff = slot.join(format!("{}whatever.tmp", RestrictiveHandoff::NAME_PREFIX));
9681        fs::write(&handoff, JIT.as_bytes()).unwrap();
9682        let quarantine = verify_slot_scrubbed(&slot).unwrap_err();
9683        assert!(
9684            quarantine.detail.contains("an encoded JIT handoff"),
9685            "{quarantine}"
9686        );
9687        assert!(!quarantine.detail.contains(JIT), "{quarantine}");
9688        fs::remove_file(&handoff).unwrap();
9689
9690        // A name a workflow chose is counted and never echoed: a slot root is
9691        // writable by the job, so a file named after a secret would be published
9692        // by any message that repeated the listing.
9693        fs::write(slot.join("ghp_DO_NOT_LEAK"), b"named by the job").unwrap();
9694        let quarantine = verify_slot_scrubbed(&slot).unwrap_err();
9695        assert!(
9696            quarantine.detail.contains("1 entry other than"),
9697            "{quarantine}"
9698        );
9699        assert!(
9700            !quarantine.detail.contains("ghp_DO_NOT_LEAK"),
9701            "{quarantine}"
9702        );
9703    }
9704
9705    #[test]
9706    fn a_slot_is_derived_from_the_journal_and_refused_when_it_disagrees() {
9707        let root = tempfile::tempdir().unwrap();
9708        let configured =
9709            LocalAbsolutePath::new(root.path().to_str().unwrap()).expect("a local absolute root");
9710        let slot = configured.as_path().join("s1");
9711        fs::create_dir(&slot).unwrap();
9712
9713        verify_journalled_slot(&slot, nz(1), Some(&configured))
9714            .expect("the journalled slot agrees");
9715        verify_journalled_slot(&slot, nz(1), None)
9716            .expect("and a policy that is gone removes a check, not the ability to clean");
9717
9718        // The journalled slot number is what names the directory. `s1` recorded
9719        // as slot two is corrupt state, not a slot to clean.
9720        assert_eq!(
9721            verify_journalled_slot(&slot, nz(2), None)
9722                .unwrap_err()
9723                .refusal,
9724            SlotRefusal::NotTheJournalledSlot
9725        );
9726        for stray in ["s1/nested", "not-a-slot", "s01"] {
9727            let path = configured.as_path().join(stray);
9728            assert_eq!(
9729                verify_journalled_slot(&path, nz(1), None)
9730                    .unwrap_err()
9731                    .refusal,
9732                SlotRefusal::NotTheJournalledSlot,
9733                "{}",
9734                path.display()
9735            );
9736        }
9737
9738        // A surviving policy that names a different root does not get to have
9739        // its disagreement resolved by deleting something.
9740        let elsewhere = tempfile::tempdir().unwrap();
9741        let other =
9742            LocalAbsolutePath::new(elsewhere.path().to_str().unwrap()).expect("a second root");
9743        assert_eq!(
9744            verify_journalled_slot(&slot, nz(1), Some(&other))
9745                .unwrap_err()
9746                .refusal,
9747            SlotRefusal::PolicyRootDisagrees
9748        );
9749    }
9750
9751    #[cfg(unix)]
9752    #[test]
9753    fn a_substituted_work_directory_quarantines_the_slot_and_deletes_nothing_outside_it() {
9754        // Windows needs a privilege to create a junction or a symlink, so the
9755        // substitution is made here; the rule is platform-independent because it
9756        // is `symlink_metadata`'s answer plus the reparse attribute.
9757        let root = tempfile::tempdir().unwrap();
9758        let outside = root.path().join("operator-data");
9759        fs::create_dir(&outside).unwrap();
9760        let sentinel = outside.join("do-not-delete.txt");
9761        fs::write(
9762            &sentinel,
9763            b"an operator's data, outside every approved root",
9764        )
9765        .unwrap();
9766
9767        let slot = root.path().join("s1");
9768        fs::create_dir(&slot).unwrap();
9769        fs::create_dir(slot.join("bin")).unwrap();
9770        std::os::unix::fs::symlink(&outside, slot.join(DEFAULT_WORK_FOLDER)).unwrap();
9771
9772        let quarantine = scrub_slot_entries(&slot).unwrap_err();
9773        assert_eq!(quarantine.refusal, SlotRefusal::WorkNotADirectory);
9774        assert!(
9775            sentinel.exists(),
9776            "the deletion followed the link out of the slot"
9777        );
9778        assert!(outside.is_dir());
9779        assert!(
9780            slot.join(DEFAULT_WORK_FOLDER).symlink_metadata().is_ok(),
9781            "the substituted link is left for the operator, never unlinked as if it were ours"
9782        );
9783
9784        // A `_work` that is a plain file is the same refusal for the same
9785        // reason: it is not a job workspace, and this is not the code that
9786        // decides what to do about it.
9787        let file_work = root.path().join("s2");
9788        fs::create_dir(&file_work).unwrap();
9789        fs::write(file_work.join(DEFAULT_WORK_FOLDER), b"not a directory").unwrap();
9790        assert_eq!(
9791            scrub_slot_entries(&file_work).unwrap_err().refusal,
9792            SlotRefusal::WorkNotADirectory
9793        );
9794    }
9795
9796    #[cfg(unix)]
9797    #[test]
9798    fn a_slot_replaced_by_a_link_out_of_its_root_is_refused_before_anything_is_read() {
9799        let root = tempfile::tempdir().unwrap();
9800        let outside = root.path().join("operator-data");
9801        fs::create_dir(&outside).unwrap();
9802        let sentinel = outside.join("do-not-delete.txt");
9803        fs::write(
9804            &sentinel,
9805            b"an operator's data, outside every approved root",
9806        )
9807        .unwrap();
9808
9809        // The lexical half of containment passes -- the name is right and the
9810        // parent is right -- and canonical resolution is what catches it.
9811        let inside = root.path().join("inside");
9812        fs::create_dir(&inside).unwrap();
9813        let slot = inside.join("s1");
9814        std::os::unix::fs::symlink(&outside, &slot).unwrap();
9815
9816        assert_eq!(
9817            verify_journalled_slot(&slot, nz(1), None)
9818                .unwrap_err()
9819                .refusal,
9820            SlotRefusal::Containment
9821        );
9822        assert!(sentinel.exists());
9823        assert!(
9824            slot.symlink_metadata().is_ok(),
9825            "the link is left for the operator rather than removed as if it were ours"
9826        );
9827    }
9828
9829    /// The Windows half of the case above.
9830    ///
9831    /// Containment was proven only on Unix, and a symbolic link is the wrong
9832    /// instrument to prove it with on Windows: creating one needs a privilege an
9833    /// ordinary workflow does not have, so it is not the substitution an
9834    /// attacker would reach for. A **junction** needs none, which makes it both
9835    /// the realistic attack and the one this repository must refuse -- and
9836    /// `04-security-recovery.md` names it in the same breath as the symlink for
9837    /// exactly that reason.
9838    #[cfg(windows)]
9839    #[test]
9840    fn a_slot_root_replaced_by_a_junction_is_refused_before_anything_is_read() {
9841        let root = tempfile::tempdir().unwrap();
9842        let outside = root.path().join("operator-data");
9843        fs::create_dir(&outside).unwrap();
9844        let sentinel = outside.join("do-not-delete.txt");
9845        fs::write(
9846            &sentinel,
9847            b"an operator's data, outside every approved root",
9848        )
9849        .unwrap();
9850
9851        // The lexical half of containment passes -- `s1` under the root the
9852        // journal names -- and canonical resolution is what catches it.
9853        let inside = root.path().join("inside");
9854        fs::create_dir(&inside).unwrap();
9855        let slot = inside.join("s1");
9856        let Some(()) = plant_junction(&slot, &outside) else {
9857            eprintln!("skipped: this machine would not create a directory junction");
9858            return;
9859        };
9860
9861        assert_eq!(
9862            verify_journalled_slot(&slot, nz(1), None)
9863                .unwrap_err()
9864                .refusal,
9865            SlotRefusal::Containment
9866        );
9867        assert!(
9868            sentinel.exists(),
9869            "the refusal resolved the junction and reached the operator's data"
9870        );
9871        assert!(
9872            slot.symlink_metadata().is_ok(),
9873            "the junction is left for the operator rather than removed as if it were ours"
9874        );
9875    }
9876
9877    /// Plant a directory junction at `link` pointing at `target`.
9878    ///
9879    /// A junction is the Windows substitution this has to refuse, and unlike a
9880    /// symbolic link it needs no privilege — which is exactly why it is the one
9881    /// an unprivileged workflow would reach for. `mklink` is a `cmd` builtin, so
9882    /// there is no binary to find and nothing to install; `None` means this
9883    /// machine would not make one and the caller says so rather than asserting
9884    /// nothing.
9885    #[cfg(windows)]
9886    fn plant_junction(link: &Path, target: &Path) -> Option<()> {
9887        let made = std::process::Command::new("cmd")
9888            .arg("/C")
9889            .arg("mklink")
9890            .arg("/J")
9891            .arg(link)
9892            .arg(target)
9893            .output()
9894            .ok()?;
9895        (made.status.success() && link.symlink_metadata().is_ok()).then_some(())
9896    }
9897
9898    #[cfg(windows)]
9899    #[test]
9900    fn a_work_directory_replaced_by_a_junction_fails_closed_and_deletes_nothing_beyond_it() {
9901        let root = tempfile::tempdir().unwrap();
9902        let outside = root.path().join("operator-data");
9903        fs::create_dir(&outside).unwrap();
9904        let sentinel = outside.join("do-not-delete.txt");
9905        fs::write(
9906            &sentinel,
9907            b"an operator's data, outside every approved root",
9908        )
9909        .unwrap();
9910
9911        let slot = root.path().join("s1");
9912        fs::create_dir(&slot).unwrap();
9913        fs::create_dir(slot.join("bin")).unwrap();
9914        let Some(()) = plant_junction(&slot.join(DEFAULT_WORK_FOLDER), &outside) else {
9915            eprintln!("skipped: this machine would not create a directory junction");
9916            return;
9917        };
9918
9919        // The reparse point is what `is_link_like` answers on, so a junction is
9920        // refused for the same reason a symbolic link is and neither is
9921        // descended into.
9922        let work = fs::symlink_metadata(slot.join(DEFAULT_WORK_FOLDER)).unwrap();
9923        assert!(is_link_like(&work), "a junction is a reparse point");
9924        let quarantine = scrub_slot_entries(&slot).unwrap_err();
9925        assert_eq!(quarantine.refusal, SlotRefusal::WorkNotADirectory);
9926        assert!(
9927            sentinel.exists(),
9928            "the deletion followed the junction out of the slot"
9929        );
9930        assert!(outside.is_dir());
9931
9932        // A junction standing where an ordinary entry was is unlinked rather
9933        // than followed, so the removal still cannot reach through it.
9934        let elsewhere = root.path().join("s2");
9935        fs::create_dir(&elsewhere).unwrap();
9936        fs::create_dir(elsewhere.join(DEFAULT_WORK_FOLDER)).unwrap();
9937        if plant_junction(&elsewhere.join("externals"), &outside).is_some() {
9938            scrub_slot_entries(&elsewhere).expect("an ordinary entry is removed, junction or not");
9939            verify_slot_scrubbed(&elsewhere).expect("and the slot verifies");
9940            assert!(sentinel.exists(), "the junction was followed, not unlinked");
9941            assert_eq!(entries_of(&elsewhere), only_the_job_workspace());
9942        }
9943    }
9944
9945    #[cfg(unix)]
9946    #[tokio::test]
9947    async fn a_substituted_work_directory_leaves_the_attempt_uncleaned_and_still_leased() {
9948        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
9949            .with_persistent_workspace(2);
9950        harness.ready().await;
9951        let first = harness.launch().await;
9952        let slot = harness.slot_path(1);
9953        harness.conclude(first.id);
9954
9955        let outside = harness._root.path().join("operator-data");
9956        fs::create_dir_all(&outside).unwrap();
9957        let sentinel = outside.join("do-not-delete.txt");
9958        fs::write(&sentinel, b"outside every approved root").unwrap();
9959        std::os::unix::fs::symlink(&outside, slot.join(DEFAULT_WORK_FOLDER)).unwrap();
9960
9961        harness
9962            .launcher
9963            .clean(first.id)
9964            .await
9965            .expect_err("a slot whose `_work` was substituted is quarantined");
9966        assert!(sentinel.exists());
9967
9968        let held = harness.attempt(first.id);
9969        assert_eq!(held.state(), AttemptState::Failed);
9970        assert!(held.holds_slot_lease());
9971
9972        // The quarantined slot is not silently chosen again.
9973        let second = harness.launch().await;
9974        assert_eq!(second.workspace(), AttemptWorkspace::persistent_slot(nz(2)));
9975    }
9976
9977    #[test]
9978    fn a_slot_that_is_a_file_is_refused_and_a_slot_that_is_gone_is_not() {
9979        let root = tempfile::tempdir().unwrap();
9980        let occupied = root.path().join("s1");
9981        fs::write(&occupied, b"an operator's file").unwrap();
9982        // The journal check passes -- it is the right name under the right root
9983        // -- and the shape check is what refuses.
9984        verify_journalled_slot(&occupied, nz(1), None).expect("the path is the journalled slot");
9985        assert_eq!(
9986            slot_is_present(&occupied).unwrap_err().refusal,
9987            SlotRefusal::SlotNotADirectory
9988        );
9989        assert_eq!(fs::read_to_string(&occupied).unwrap(), "an operator's file");
9990
9991        // A directory that is simply not there leaves nothing to remove and
9992        // nothing to prove absent, so it is not a refusal.
9993        assert!(!slot_is_present(&root.path().join("s2")).unwrap());
9994        let present = root.path().join("s3");
9995        fs::create_dir(&present).unwrap();
9996        assert!(slot_is_present(&present).unwrap());
9997    }
9998
9999    #[test]
10000    fn cleanup_dispatches_on_the_journalled_kind_and_not_on_what_the_directory_holds() {
10001        let root = tempfile::tempdir().unwrap();
10002
10003        // A disposable directory that happens to contain a `_work` still goes
10004        // whole: the workspace kind is immutable so that the shape of a
10005        // directory a workflow can write to cannot choose its own algorithm.
10006        let disposable = root.path().join("abcdef012345");
10007        fs::create_dir_all(disposable.join(DEFAULT_WORK_FOLDER).join("repo")).unwrap();
10008        let ephemeral = RunnerAttempt::allocate(
10009            AttemptId::new_random(),
10010            fixtures::POLICY_ID,
10011            &disposable,
10012            fixtures::created_at(),
10013        );
10014        remove_materialized_package(&ephemeral).unwrap();
10015        assert!(!disposable.exists());
10016
10017        // And a slot keeps its `_work` with the same contents beneath it.
10018        let slot = root.path().join("s1");
10019        fs::create_dir_all(slot.join(DEFAULT_WORK_FOLDER).join("repo")).unwrap();
10020        fs::create_dir_all(slot.join("bin")).unwrap();
10021        let persistent = RunnerAttempt::allocate_in(
10022            AttemptId::new_random(),
10023            fixtures::POLICY_ID,
10024            &slot,
10025            AttemptWorkspace::persistent_slot(nz(1)),
10026            fixtures::created_at(),
10027        );
10028        remove_materialized_package(&persistent).unwrap();
10029        assert_eq!(entries_of(&slot), only_the_job_workspace());
10030        assert!(slot.join(DEFAULT_WORK_FOLDER).join("repo").is_dir());
10031    }
10032
10033    #[test]
10034    fn every_slot_refusal_names_a_distinct_event_class_and_keeps_the_lease() {
10035        let refusals = [
10036            SlotRefusal::NotTheJournalledSlot,
10037            SlotRefusal::PolicyRootDisagrees,
10038            SlotRefusal::Containment,
10039            SlotRefusal::SlotNotADirectory,
10040            SlotRefusal::Enumeration,
10041            SlotRefusal::WorkNotADirectory,
10042            SlotRefusal::Deletion,
10043            SlotRefusal::Residue,
10044        ];
10045        let classes: BTreeSet<&str> = refusals.iter().map(|refusal| refusal.class()).collect();
10046        assert_eq!(
10047            classes.len(),
10048            refusals.len(),
10049            "an event class shared by two refusals tells an operator less than it appears to"
10050        );
10051        for refusal in refusals {
10052            // The event field is a closed vocabulary, so it has to look like
10053            // one: `d1`'s sink allows the name verbatim.
10054            assert!(
10055                refusal
10056                    .class()
10057                    .chars()
10058                    .all(|c| c.is_ascii_lowercase() || c == '_'),
10059                "{}",
10060                refusal.class()
10061            );
10062            assert!(
10063                refusal.remediation().contains("slot lease"),
10064                "every refusal has to say the lease is still held: {}",
10065                refusal.class()
10066            );
10067        }
10068    }
10069
10070    #[test]
10071    fn copy_package_tree_copies_files_and_preserves_paths_with_spaces() {
10072        let root = tempfile::tempdir().unwrap();
10073        let source = root.path().join("source with spaces");
10074        let dest = root.path().join("dest with spaces");
10075
10076        fs::create_dir_all(&source).unwrap();
10077        fs::write(source.join("file1.txt"), b"hello").unwrap();
10078
10079        let nested = source.join("nested dir");
10080        fs::create_dir_all(&nested).unwrap();
10081        fs::write(nested.join("file2.txt"), b"world").unwrap();
10082
10083        // This is not a top-level `_work`, so it should be allowed
10084        let nested_work = nested.join(DEFAULT_WORK_FOLDER);
10085        fs::create_dir_all(&nested_work).unwrap();
10086        fs::write(nested_work.join("allowed.txt"), b"allowed").unwrap();
10087
10088        copy_package_tree(&source, &dest, &TrustedTrees::default()).unwrap();
10089
10090        assert_eq!(fs::read_to_string(dest.join("file1.txt")).unwrap(), "hello");
10091        assert_eq!(
10092            fs::read_to_string(dest.join("nested dir").join("file2.txt")).unwrap(),
10093            "world"
10094        );
10095        assert_eq!(
10096            fs::read_to_string(
10097                dest.join("nested dir")
10098                    .join(DEFAULT_WORK_FOLDER)
10099                    .join("allowed.txt")
10100            )
10101            .unwrap(),
10102            "allowed"
10103        );
10104    }
10105
10106    #[test]
10107    fn every_field_name_the_attempt_sink_emits_is_allowed() {
10108        use runner_manager_platform::logging::is_field_allowed;
10109
10110        // Kept beside the sink rather than derived from it, as in `reconcile`.
10111        for field in [
10112            "event",
10113            "attempt_id",
10114            "reason",
10115            "retry_in_ms",
10116            "duration_ms",
10117            "lock_wait_ms",
10118            "materialize_ms",
10119            "register_ms",
10120            "spawn_ms",
10121        ] {
10122            assert!(
10123                is_field_allowed(field),
10124                "`{field}` would be logged as `[redacted]`"
10125            );
10126        }
10127    }
10128
10129    #[cfg(unix)]
10130    fn package_entry(root: &Path, version: &str, manifest: &str) -> PathBuf {
10131        let entry = root.join("packages").join(version);
10132        fs::create_dir_all(entry.join("bin")).unwrap();
10133        fs::write(entry.join(crate::package::MANIFEST_FILE), manifest).unwrap();
10134        fs::write(entry.join("bin").join("Runner.Listener"), version).unwrap();
10135        entry
10136    }
10137
10138    /// The seed's binary's inode: a rebuilt seed holds a new file.
10139    #[cfg(unix)]
10140    fn listener_inode(seed: &Path) -> u64 {
10141        use std::os::unix::fs::MetadataExt as _;
10142        fs::metadata(seed.join("bin").join("Runner.Listener"))
10143            .unwrap()
10144            .ino()
10145    }
10146
10147    #[cfg(unix)]
10148    #[test]
10149    fn a_package_seed_is_built_once_and_reused_while_untouched() {
10150        let root = tempfile::tempdir().unwrap();
10151        let source = package_entry(root.path(), "2.336.0", "{\"digest\":\"a\"}");
10152        let seeds = root.path().join("runners").join(PACKAGE_SEED_DIR);
10153        let trusted = TrustedTrees::default();
10154
10155        let seed = refresh_package_seed(&source, &seeds, &trusted).unwrap();
10156        assert_eq!(seed, seeds.join("2.336.0"));
10157        assert_eq!(
10158            fs::read_to_string(seed.join("bin").join("Runner.Listener")).unwrap(),
10159            "2.336.0"
10160        );
10161
10162        let built = listener_inode(&seed);
10163        assert_eq!(
10164            refresh_package_seed(&source, &seeds, &trusted).unwrap(),
10165            seed
10166        );
10167        assert_eq!(listener_inode(&seed), built, "an untouched seed is reused");
10168    }
10169
10170    #[cfg(unix)]
10171    #[test]
10172    fn a_tampered_or_unrecorded_seed_is_rebuilt() {
10173        let root = tempfile::tempdir().unwrap();
10174        let source = package_entry(root.path(), "2.336.0", "{\"digest\":\"a\"}");
10175        let seeds = root.path().join("runners").join(PACKAGE_SEED_DIR);
10176        let trusted = TrustedTrees::default();
10177        let seed = refresh_package_seed(&source, &seeds, &trusted).unwrap();
10178
10179        // A job rewrites the binary in place and leaves the manifest alone.
10180        fs::write(seed.join("bin").join("Runner.Listener"), b"planted").unwrap();
10181        refresh_package_seed(&source, &seeds, &trusted).unwrap();
10182        assert_eq!(
10183            fs::read_to_string(seed.join("bin").join("Runner.Listener")).unwrap(),
10184            "2.336.0",
10185            "a seed whose print moved is rebuilt from the cache entry"
10186        );
10187
10188        // A restarted daemon has no print of a seed it did not build.
10189        // Keep the old inode allocated until the replacement is complete;
10190        // otherwise the filesystem may immediately reuse its number.
10191        let old_listener = fs::File::open(seed.join("bin").join("Runner.Listener")).unwrap();
10192        let built = listener_inode(&seed);
10193        refresh_package_seed(&source, &seeds, &TrustedTrees::default()).unwrap();
10194        assert_ne!(
10195            listener_inode(&seed),
10196            built,
10197            "an unrecorded seed is rebuilt"
10198        );
10199        drop(old_listener);
10200    }
10201
10202    #[cfg(unix)]
10203    #[test]
10204    fn a_package_seed_is_rebuilt_for_a_new_entry_and_drops_every_other() {
10205        let root = tempfile::tempdir().unwrap();
10206        let seeds = root.path().join("runners").join(PACKAGE_SEED_DIR);
10207        let trusted = TrustedTrees::default();
10208        let old = package_entry(root.path(), "2.335.0", "{\"digest\":\"old\"}");
10209        refresh_package_seed(&old, &seeds, &trusted).unwrap();
10210        fs::create_dir_all(seeds.join(".staging-interrupted")).unwrap();
10211        fs::write(seeds.join("stray"), b"").unwrap();
10212
10213        let new = package_entry(root.path(), "2.336.0", "{\"digest\":\"new\"}");
10214        let seed = refresh_package_seed(&new, &seeds, &trusted).unwrap();
10215
10216        let mut left: Vec<_> = fs::read_dir(&seeds)
10217            .unwrap()
10218            .map(|entry| entry.unwrap().file_name().into_string().unwrap())
10219            .collect();
10220        left.sort();
10221        assert_eq!(left, ["2.336.0"]);
10222
10223        // A reinstalled entry of the same version has a different manifest.
10224        fs::write(
10225            new.join(crate::package::MANIFEST_FILE),
10226            "{\"digest\":\"again\"}",
10227        )
10228        .unwrap();
10229        refresh_package_seed(&new, &seeds, &trusted).unwrap();
10230        assert_eq!(
10231            fs::read_to_string(seed.join(crate::package::MANIFEST_FILE)).unwrap(),
10232            "{\"digest\":\"again\"}",
10233            "a stale seed is rebuilt from the changed entry"
10234        );
10235    }
10236
10237    #[cfg(unix)]
10238    #[test]
10239    fn a_package_on_the_runtime_volume_is_its_own_seed() {
10240        let root = tempfile::tempdir().unwrap();
10241        let source = package_entry(root.path(), "2.336.0", "{}");
10242        let runtime = root.path().join("runners").join("attempt");
10243        fs::create_dir_all(&runtime).unwrap();
10244
10245        let trusted = TrustedTrees::default();
10246        assert_eq!(package_seed(&source, &runtime, &trusted).unwrap(), source);
10247        copy_package_tree(&source, &runtime, &trusted).unwrap();
10248        assert!(runtime.join("bin").join("Runner.Listener").exists());
10249        assert!(!root.path().join("runners").join(PACKAGE_SEED_DIR).exists());
10250    }
10251
10252    #[test]
10253    fn copy_package_tree_refuses_top_level_work_folder() {
10254        let root = tempfile::tempdir().unwrap();
10255        let source = root.path().join("source");
10256        let dest = root.path().join("dest");
10257
10258        fs::create_dir_all(&source).unwrap();
10259        fs::write(source.join("file1.txt"), b"hello").unwrap();
10260
10261        // Top-level `_work` should be refused
10262        let top_work = source.join(DEFAULT_WORK_FOLDER);
10263        fs::create_dir_all(&top_work).unwrap();
10264
10265        let err = copy_package_tree(&source, &dest, &TrustedTrees::default()).unwrap_err();
10266        assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
10267    }
10268}