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