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