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