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