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