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