Skip to main content

runner_manager_agent/
reconcile.rs

1// owner: e1-reconciliation-capacity
2
3//! The loop that turns GitHub demand into a decision to start runners — and
4//! that refuses to start them when it should not.
5//!
6//! Every ceiling in this product is enforced from here, so the module is
7//! organised around the four things that can go wrong silently:
8//!
9//! * [`PollSchedule`] — the budget-aware interval. Demand shares one 5,000
10//!   requests/hour ceiling with inventory and workflow counts, so this loop
11//!   polls on a bounded interval (default 60 s, hard floor 30 s per target) and
12//!   *increases* the delay under a rate-limit signal, never decreases it to
13//!   catch up.
14//! * [`RepositoryCache`] — the per-organization repository list, refreshed on
15//!   an interval materially slower than the demand poll. Re-listing an
16//!   organization at demand-poll frequency is what exhausts the shared budget
17//!   the paragraph above exists to protect.
18//! * [`Reconciler::reconcile`] — the allocation pass. It re-reads the attempt
19//!   set **under the host-wide allocation lock, once per runtime created**, so
20//!   two policies reconciling concurrently cannot both spend the same headroom.
21//! * [`LifecycleEvent`] — what `g2` and the local log sink see. Every field is
22//!   an identifier, a count, an enumerated state or a duration; nothing free
23//!   text, and nothing that came off the wire.
24//!
25//! # There is no acquisition step, and none may be added
26//!
27//! The scale-set model called `AcquireJobs` to reserve an assignment before
28//! scaling. The REST path has no equivalent (`01-current-architecture.md`, edge
29//! case 6), so demand is **advisory**. Two consequences are load-bearing here
30//! and neither is a defect:
31//!
32//! 1. **A surplus runner is an accepted outcome.** Another host serving the
33//!    same labels may take the job first; this host's runner then finds no work
34//!    and exits on its idle timeout, having cost one capacity slot and one cold
35//!    start. That terminal outcome is
36//!    [`AttemptOutcome::ExitedIdleWithoutWork`], is cleaned like any other, and
37//!    is counted apart from a failure — see [`ReconcileReport::idle_exits`].
38//! 2. **The same job is still `queued` on the next poll** while its runner
39//!    starts. The `- active_owned_runners` term in
40//!    [`HostAllocator::allocate`] is what stops that from starting a second
41//!    runner, and then a third. This module's only job in that arithmetic is to
42//!    hand the allocator the attempt set the host actually holds — which is why
43//!    [`RunnerLauncher`] supplies both the attempts and the launch, from one
44//!    supply point, for the reason `b1` gives at
45//!    [`HostAllocator::from_attempts`].
46//!
47//! `tests::nothing_in_this_module_reserves_or_claims_a_job` is a tripwire on the
48//! obvious shape of a reservation being added back.
49//!
50//! # Demand is measured in JOBS, filtered by this policy's routing labels
51//!
52//! `02-target-architecture.md` writes the formula as *"queued jobs whose
53//! `runs-on` matches this policy's routing labels"*, and that is now exactly
54//! what this module clamps. It was not always: an earlier owner decision priced
55//! the per-run job listing out and left this module clamping a count of
56//! **runs**, unfiltered. `crates/github/src/demand.rs` records that decision,
57//! why it was reversed, and what the reversal costs in requests.
58//!
59//! What the reversal means here is two changes to one line:
60//!
61//! * **A run of eight jobs is now eight units of demand, not one.** Under the
62//!   run count a matrix filled one runner per poll while the rest of the matrix
63//!   waited, so a host configured for ten concurrent runners served an
64//!   eight-job matrix nearly serially. That was the defect that forced the
65//!   decision back.
66//! * **A job this host cannot serve is no longer demand.** A repository whose
67//!   jobs target `ubuntu-latest`, or another host's `rm-<host>-…` label, used to
68//!   drive its policy toward `max_capacity` and start runners that idled until
69//!   they timed out. The gateway now returns each queued job's `runs-on`, so
70//!   `b1`'s predicate finally has its input.
71//!
72//! **The predicate is still `b1`'s and the input is still `c4`'s.** This module
73//! calls [`runner_manager_domain::policy::RoutingLabels`]'s `tally` and
74//! implements no label comparison of its own;
75//! `tests::the_label_predicate_is_b1s_and_this_module_only_applies_it` scans
76//! this file's own source and fails if a second implementation grows here, which
77//! is the same tripwire `c4` carries one layer down.
78//!
79//! # The filtering happens here rather than in the gateway, on purpose
80//!
81//! One target can be watched by more than one policy, each with its own routing
82//! labels, and [`Reconciler`]'s `poll_targets` deliberately polls a target **once**
83//! for all of them. A gateway that filtered would have to be told whose labels to
84//! filter by, which would make the poll per-policy and multiply its request cost
85//! by the number of policies sharing the target — the budget model prices a
86//! target, not a policy. So the gateway returns the jobs and each policy tallies
87//! them against its own labels.
88//!
89//! # What is still approximate
90//!
91//! The surplus-runner path above is narrowed by this change and not closed. A
92//! `runs-on: ${{ matrix.runner }}` cannot be resolved without evaluating the
93//! workflow, so `b1` reports it as unresolvable: never counted as demand, never
94//! silently dropped, and surfaced through
95//! [`LifecycleEvent::DemandObserved::unresolvable`] so that an operator can see
96//! a workflow this host will never serve sitting in the queue. And demand
97//! remains advisory — another host may still take a job this one started a
98//! runner for — which is what the two ceilings bound.
99//!
100//! # What is testable without a network, a filesystem, or a process
101//!
102//! All of it. [`DemandSource`], [`RunnerLauncher`], [`AllocationLock`],
103//! [`RepositoryDirectory`], [`Jitter`] and [`EventSink`] are ports;
104//! [`GatewayDemand`], [`FileAllocationLock`], [`RandomJitter`] and
105//! [`TracingEvents`] are the production adapters, and every one of them is a
106//! thin shell over a decision made in this file.
107
108use std::collections::{BTreeMap, BTreeSet};
109use std::fmt;
110use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
111use std::sync::{Arc, Mutex};
112use std::time::Duration;
113
114use runner_manager_domain::attempt::{
115    AttemptOutcome, AttemptState, FailureReason, IsolationProviderFailure, RunnerAttempt,
116};
117use runner_manager_domain::capacity::{Allocation, HostAllocator, LimitingFactor};
118use runner_manager_domain::model::{
119    AttemptId, Clock, Host, Org, OwnerRepo, PolicyId, RefreshInterval, ScaleTarget, Timestamp,
120};
121use runner_manager_domain::policy::{DemandTally, ScalePolicy};
122use runner_manager_github::demand::{DemandGateway, QueuedDemand, demand_requests_per_poll};
123use runner_manager_github::rest::{
124    ActivityScope, CancelToken, InventoryError, RateLimitKind, RefreshState,
125};
126
127// ---------------------------------------------------------------------------
128// Constants
129// ---------------------------------------------------------------------------
130
131/// How much slower than the demand poll the per-organization repository list is
132/// refreshed.
133///
134/// There is no organization-wide workflow-runs endpoint, so an organization
135/// target costs one demand request **per repository the App is installed on**
136/// (`crates/github/src/demand.rs`). Discovering that repository list costs
137/// requests of its own, and it is the one input to a demand poll that changes on
138/// a human timescale: repositories are added to an installation by hand, not by
139/// a workflow starting.
140///
141/// Thirty polls is 30 minutes at the 60-second default and 15 at the 30-second
142/// floor — slow enough that the list is a rounding error against the demand
143/// requests it scopes, and fast enough that a repository added to the
144/// installation starts being served within one coffee break rather than at the
145/// next restart.
146pub const REPOSITORY_LIST_REFRESH_MULTIPLE: u32 = 30;
147
148/// The longest the *unjittered* offline back-off may grow to.
149///
150/// A back-off is a safety mechanism, and an unclamped one is an outage with
151/// extra steps. Fifteen minutes matches
152/// [`runner_manager_github::rest::MAX_RATE_LIMIT_BACKOFF`], which is the other
153/// place in this product where a delay is allowed to grow, and it is far inside
154/// the 24-hour bound at which GitHub cancels the queued jobs this loop exists to
155/// serve.
156pub const MAX_OFFLINE_BACKOFF: Duration = Duration::from_secs(15 * 60);
157
158/// The most the offline back-off is doubled, before the cap applies.
159///
160/// At the 60-second default this reaches [`MAX_OFFLINE_BACKOFF`] on the sixth
161/// consecutive failure, which is roughly half an hour of outage. Past that the
162/// cap holds it flat.
163const MAX_BACKOFF_DOUBLINGS: u32 = 5;
164
165/// How much of the computed back-off is jitter.
166///
167/// Jitter is **added** rather than subtracted, so a back-off never comes out
168/// shorter than the delay it was computed from. Subtractive jitter would let the
169/// first offline poll retry sooner than the nominal interval, which is the
170/// opposite of backing off; it is spelled out because "add jitter" reads as
171/// symmetric and is not.
172const JITTER_RATIO: f64 = 0.5;
173
174/// GitHub cancels a queued job after this long.
175///
176/// `01-current-architecture.md` records the measurement; `03-control-flows.md`
177/// flow 3.3 requires that the offline state **states** it, because an agent
178/// offline for longer than this has lost queued work and the operator cannot
179/// infer that from "offline". [`OfflineState`] is where it is said.
180pub const GITHUB_CANCELS_QUEUED_JOBS_AFTER: Duration = Duration::from_secs(24 * 60 * 60);
181
182/// How long [`FileAllocationLock`] waits for the host-wide allocation lock
183/// before reporting contention.
184///
185/// Contention here is expected rather than exceptional — it is two of this
186/// host's own policies creating runtimes at the same moment — and each hold
187/// lasts only as long as one runtime creation. Waiting a few seconds turns the
188/// common case into a short pause instead of a skipped runner.
189///
190/// # How many of these a poll actually costs
191///
192/// One per runtime created, none for a policy that is granted nothing, and at
193/// most one further hold per policy — the case where the pre-check proposed a
194/// grant and the under-lock re-read found the host had filled up underneath it,
195/// so that hold creates nothing and ends the loop. `(3..=5)` in
196/// `two_policies_reconciling_concurrently_never_exceed_host_capacity` is that
197/// bound with two policies and three runtimes; the deterministic single-policy
198/// case is pinned at exactly one per runtime.
199///
200/// That is worth stating because it did not used to be true and the
201/// difference only shows up here: the budget was checked *after* the lock had
202/// been taken and the attempt set re-read, so a policy granted N runners took
203/// N+1 holds, and `start_runners` ran for every readable autoscale policy
204/// including the zero-demand ones — so an idle host with P policies took P
205/// host-wide locks per poll for nothing. Free under
206/// [`InProcessAllocationLock`]; under [`FileAllocationLock`] each one is a
207/// `spawn_blocking` plus a filesystem lock, with this wait behind it.
208///
209/// [`Reconciler::start_runners`] now pre-checks lock-free and stops as soon as
210/// the budget is spent. The under-lock re-read still decides.
211pub const ALLOCATION_LOCK_WAIT: Duration = Duration::from_secs(5);
212
213// ---------------------------------------------------------------------------
214// What one demand poll produced
215// ---------------------------------------------------------------------------
216
217/// One target's demand poll, as a value this module can decide from.
218///
219/// The failure half is `c3`'s [`RefreshState`] rather than an
220/// [`InventoryError`], for the reason `c3` gives: `InventoryError` owns a
221/// `reqwest::Error` and a `serde_json::Error`, so it is neither `Clone` nor
222/// `PartialEq` and cannot be stored, compared, or rendered. Summarising at the
223/// gateway boundary — exactly once, in [`GatewayDemand`] — is what lets the
224/// whole schedule below be a pure function of values a test can construct.
225///
226/// [`RefreshState::Ready`] never appears in [`PollOutcome::Failed`]:
227/// [`RefreshState::from_error`] cannot produce it, and a demand poll returns a
228/// [`QueuedDemand`] rather than the runner inventory that variant carries.
229#[derive(Debug, Clone, PartialEq, Eq)]
230pub enum PollOutcome {
231    /// GitHub answered. The count may still be a floor — see
232    /// [`QueuedDemand::is_complete`].
233    Ready(QueuedDemand),
234    /// GitHub did not answer, or answered something this loop must slow down
235    /// for.
236    Failed(RefreshState),
237}
238
239impl PollOutcome {
240    /// The demand reading, when there is one.
241    #[must_use]
242    pub const fn reading(&self) -> Option<&QueuedDemand> {
243        match self {
244            Self::Ready(demand) => Some(demand),
245            Self::Failed(_) => None,
246        }
247    }
248
249    /// The failure, when there is one.
250    #[must_use]
251    pub const fn failure(&self) -> Option<&RefreshState> {
252        match self {
253            Self::Failed(state) => Some(state),
254            Self::Ready(_) => None,
255        }
256    }
257
258    /// Whether GitHub could not be reached at all, as opposed to answering
259    /// something unwelcome.
260    ///
261    /// The whole of flow 3.3 turns on this distinction: an outage retains
262    /// running runners and backs off, while a rejection is a configuration
263    /// problem that waiting does not fix.
264    #[must_use]
265    pub fn is_offline(&self) -> bool {
266        matches!(self, Self::Failed(RefreshState::Offline))
267    }
268}
269
270/// Where this loop gets its demand from.
271///
272/// A port rather than a direct [`DemandGateway`] dependency, because the two
273/// failures this loop must handle differently — unreachable and rate-limited —
274/// are distinguished by [`RefreshState`], and a test that wants to drive the
275/// offline path should not have to manufacture a `reqwest::Error` to do it.
276/// [`GatewayDemand`] is the one adapter that talks to `c4`.
277#[async_trait::async_trait]
278pub trait DemandSource: fmt::Debug + Send + Sync {
279    /// Queued runs across `scope`, or why there are none to report.
280    async fn poll(&self, scope: &ActivityScope) -> PollOutcome;
281}
282
283/// [`DemandSource`] over `c4`'s [`DemandGateway`].
284///
285/// Holds the [`CancelToken`] so that a shutting-down daemon can withdraw a poll
286/// that is already blocked on a socket; `f3` keeps a clone and cancels it.
287#[derive(Debug)]
288pub struct GatewayDemand<G> {
289    gateway: G,
290    cancel: CancelToken,
291}
292
293impl<G: DemandGateway> GatewayDemand<G> {
294    #[must_use]
295    pub const fn new(gateway: G, cancel: CancelToken) -> Self {
296        Self { gateway, cancel }
297    }
298
299    #[must_use]
300    pub const fn gateway(&self) -> &G {
301        &self.gateway
302    }
303}
304
305#[async_trait::async_trait]
306impl<G: DemandGateway + 'static> DemandSource for GatewayDemand<G> {
307    async fn poll(&self, scope: &ActivityScope) -> PollOutcome {
308        match self.gateway.queued_demand(scope, &self.cancel).await {
309            Ok(demand) => PollOutcome::Ready(demand),
310            // The one place an `InventoryError` is summarised. `c3` owns the
311            // mapping — including transport-to-`Offline`, which is what flow
312            // 3.3 branches on — so this loop never re-decides it.
313            Err(error) => PollOutcome::Failed(RefreshState::from_error(&error)),
314        }
315    }
316}
317
318// ---------------------------------------------------------------------------
319// The repository list, cached
320// ---------------------------------------------------------------------------
321
322/// Which repositories an organization installation reaches.
323///
324/// `f1` already holds this, from
325/// [`runner_manager_github::AuthenticatedClient::discover_installations`]. It is
326/// a port here so that [`RepositoryCache`] can be tested for the property that
327/// matters — how *often* it asks — without a network.
328#[async_trait::async_trait]
329pub trait RepositoryDirectory: fmt::Debug + Send + Sync {
330    /// The repositories this credential reaches in `org`.
331    ///
332    /// # Errors
333    /// Anything the underlying gateway reports.
334    async fn repositories(&self, org: &Org) -> Result<Vec<OwnerRepo>, InventoryError>;
335}
336
337#[derive(Debug, Clone)]
338struct CachedRepositories {
339    repositories: Vec<OwnerRepo>,
340    fetched_at: Timestamp,
341}
342
343/// The per-organization repository list, refreshed far more slowly than demand.
344///
345/// # Why this is not just "call the directory each poll"
346///
347/// An organization demand poll already costs one request per repository. Adding
348/// the installation listing to every poll makes the *scoping* of a poll cost
349/// requests on the same schedule as the poll itself, which is how a
350/// ten-repository organization at the 30-second floor stops fitting inside the
351/// half-of-5,000 allowance `f2` admits targets against. The repository list is
352/// also the one input that changes on a human timescale, so refreshing it
353/// [`REPOSITORY_LIST_REFRESH_MULTIPLE`] times more slowly costs nothing real.
354///
355/// # A repository target never consults the directory at all
356///
357/// Its scope is itself. That is not an optimisation; asking an installation
358/// listing which repositories a single named repository covers would be asking a
359/// question whose answer is already in the target.
360#[derive(Debug)]
361pub struct RepositoryCache {
362    directory: Arc<dyn RepositoryDirectory>,
363    clock: Arc<dyn Clock>,
364    ttl: Duration,
365    entries: Mutex<BTreeMap<Org, CachedRepositories>>,
366    lookups: AtomicU64,
367}
368
369impl RepositoryCache {
370    /// Build a cache whose refresh interval is `poll` slowed by
371    /// [`REPOSITORY_LIST_REFRESH_MULTIPLE`].
372    #[must_use]
373    pub fn new(
374        directory: Arc<dyn RepositoryDirectory>,
375        clock: Arc<dyn Clock>,
376        poll: RefreshInterval,
377    ) -> Self {
378        let ttl = Duration::from_secs(u64::from(poll.as_secs()))
379            .saturating_mul(REPOSITORY_LIST_REFRESH_MULTIPLE);
380        Self {
381            directory,
382            clock,
383            ttl,
384            entries: Mutex::new(BTreeMap::new()),
385            lookups: AtomicU64::new(0),
386        }
387    }
388
389    /// How long a cached repository list is reused for.
390    #[must_use]
391    pub const fn ttl(&self) -> Duration {
392        self.ttl
393    }
394
395    /// How many times the underlying directory was actually asked.
396    ///
397    /// Measured rather than assumed, for the reason `c4` measures its own
398    /// request count: a budget nothing counts is a table in a document.
399    #[must_use]
400    pub fn lookups(&self) -> u64 {
401        self.lookups.load(Ordering::SeqCst)
402    }
403
404    /// The scope one demand poll of `target` covers.
405    ///
406    /// # Errors
407    /// Whatever the directory reported, for an organization target whose list is
408    /// stale or absent. A repository target cannot fail.
409    pub async fn scope_for(&self, target: &ScaleTarget) -> Result<ActivityScope, InventoryError> {
410        match target {
411            ScaleTarget::Repository(repository) => {
412                Ok(ActivityScope::repository(repository.clone()))
413            }
414            ScaleTarget::Organization(org) => {
415                let repositories = self.repositories_of(org).await?;
416                Ok(ActivityScope::organization(org.clone(), repositories))
417            }
418        }
419    }
420
421    async fn repositories_of(&self, org: &Org) -> Result<Vec<OwnerRepo>, InventoryError> {
422        let now = self.clock.now();
423        if let Some(fresh) = self.fresh_entry(org, now) {
424            return Ok(fresh);
425        }
426
427        // The directory call is deliberately made with no lock held. Two
428        // concurrent misses can therefore both ask, which costs one extra
429        // listing on the poll that follows a restart; holding a `std::sync`
430        // mutex across an `await` would cost a blocked executor thread and, on
431        // a current-thread runtime, a deadlock. The cheaper mistake is the one
432        // that spends a request.
433        let repositories = self.directory.repositories(org).await?;
434        self.lookups.fetch_add(1, Ordering::SeqCst);
435        self.store(org.clone(), repositories.clone(), now);
436        Ok(repositories)
437    }
438
439    fn fresh_entry(&self, org: &Org, now: Timestamp) -> Option<Vec<OwnerRepo>> {
440        let entries = self.entries.lock().ok()?;
441        let entry = entries.get(org)?;
442        let age = now.signed_duration_since(entry.fetched_at).to_std().ok()?;
443        (age < self.ttl).then(|| entry.repositories.clone())
444    }
445
446    fn store(&self, org: Org, repositories: Vec<OwnerRepo>, fetched_at: Timestamp) {
447        if let Ok(mut entries) = self.entries.lock() {
448            entries.insert(
449                org,
450                CachedRepositories {
451                    repositories,
452                    fetched_at,
453                },
454            );
455        }
456    }
457}
458
459// ---------------------------------------------------------------------------
460// Jitter
461// ---------------------------------------------------------------------------
462
463/// The randomness in the offline back-off, as a port.
464///
465/// Flow 3.3 requires jittered back-off, and a jittered delay is by construction
466/// not reproducible — so the source of the randomness is a port, and every test
467/// below asserts the *bounds* of the delay against a fixed fraction rather than
468/// asserting a number it could only have got by running the generator.
469pub trait Jitter: fmt::Debug + Send + Sync {
470    /// A fraction in `[0.0, 1.0)`. Values outside that range are clamped by the
471    /// caller, so an implementation cannot lengthen a back-off without bound.
472    fn fraction(&self) -> f64;
473}
474
475/// The production source.
476#[derive(Debug, Clone, Copy, Default)]
477pub struct RandomJitter;
478
479impl Jitter for RandomJitter {
480    fn fraction(&self) -> f64 {
481        rand::random::<f64>()
482    }
483}
484
485/// A fixed fraction, for tests and for the acceptance suite.
486#[derive(Debug, Clone, Copy)]
487pub struct FixedJitter(pub f64);
488
489impl Jitter for FixedJitter {
490    fn fraction(&self) -> f64 {
491        self.0
492    }
493}
494
495/// No jitter at all: the back-off is exactly what the schedule computed.
496#[derive(Debug, Clone, Copy, Default)]
497pub struct NoJitter;
498
499impl Jitter for NoJitter {
500    fn fraction(&self) -> f64 {
501        0.0
502    }
503}
504
505// ---------------------------------------------------------------------------
506// The schedule
507// ---------------------------------------------------------------------------
508
509/// Why the next poll is when it is.
510///
511/// Reported rather than inferred, because
512/// `04-subsystem-contracts.md` requires that rate limiting be *"displayed, never
513/// hidden"* — and a delay that grew for a reason the caller cannot name is
514/// hidden however visible the number is.
515#[derive(Debug, Clone, Copy, PartialEq, Eq)]
516pub enum PollPace {
517    /// The configured interval. Nothing is throttling this loop.
518    Nominal,
519    /// GitHub's rate limit is exhausted. Resolves by waiting.
520    RateLimited { kind: RateLimitKind },
521    /// GitHub's temporary authentication lockout. The credential is fine.
522    LockedOut,
523    /// GitHub could not be reached. `consecutive` counts the unbroken run of
524    /// failures the back-off was computed from.
525    Offline { consecutive: u32 },
526    /// GitHub answered something no amount of waiting fixes — a rejected
527    /// credential, a permissions refusal, or an error status. The loop keeps
528    /// polling at its nominal interval so that a fix is noticed, and says that
529    /// it is blocked rather than pretending the poll succeeded.
530    Blocked,
531}
532
533impl PollPace {
534    /// Whether this pace is a slowdown the operator should be told about.
535    #[must_use]
536    pub const fn is_throttled(&self) -> bool {
537        !matches!(self, Self::Nominal)
538    }
539
540    /// A fixed, credential-free name for the log sink and for `g2`.
541    #[must_use]
542    pub const fn as_str(&self) -> &'static str {
543        match self {
544            Self::Nominal => "nominal",
545            Self::RateLimited {
546                kind: RateLimitKind::Primary,
547            } => "rate_limited_primary",
548            Self::RateLimited {
549                kind: RateLimitKind::Secondary,
550            } => "rate_limited_secondary",
551            Self::LockedOut => "locked_out",
552            Self::Offline { .. } => "offline",
553            Self::Blocked => "blocked",
554        }
555    }
556}
557
558impl fmt::Display for PollPace {
559    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
560        f.write_str(self.as_str())
561    }
562}
563
564/// When to poll next, and why then.
565#[derive(Debug, Clone, Copy, PartialEq, Eq)]
566pub struct NextPoll {
567    pub delay: Duration,
568    pub pace: PollPace,
569}
570
571/// The bounded, budget-aware poll interval.
572///
573/// # The floor is a rate-budget constraint, not a preference
574///
575/// [`RefreshInterval`] refuses anything under 30 seconds at construction, and
576/// every delay this type produces is at least that — including the ones it
577/// computes from a remote header. A rate limit may only ever make this loop
578/// *slower*.
579///
580/// # `retry_delay` is an absolute floor, not an addend
581///
582/// `c3` documents [`RefreshState::retry_delay`] as *"the earliest time a retry
583/// may occur"*: the scheduling rule is `next_attempt_at = now + retry_delay`,
584/// and **not** the ordinary interval plus it. Adding the two compounds on every
585/// successive retry — each new answer carries the remaining window, so an
586/// addend ratchets outward — and the symptom is a dashboard that stays dark
587/// long after GitHub said it could come back, which reads as a hang rather than
588/// as a rate limit. So the two are combined with `max`, which is what makes the
589/// floor a floor.
590#[derive(Debug, Clone)]
591pub struct PollSchedule {
592    interval: RefreshInterval,
593    consecutive_offline: u32,
594    offline_since: Option<Timestamp>,
595}
596
597impl PollSchedule {
598    #[must_use]
599    pub const fn new(interval: RefreshInterval) -> Self {
600        Self {
601            interval,
602            consecutive_offline: 0,
603            offline_since: None,
604        }
605    }
606
607    #[must_use]
608    pub const fn interval(&self) -> RefreshInterval {
609        self.interval
610    }
611
612    /// The nominal interval as a [`Duration`].
613    #[must_use]
614    pub const fn nominal(&self) -> Duration {
615        Duration::from_secs(self.interval.as_secs() as u64)
616    }
617
618    /// The unbroken run of offline polls this schedule has seen.
619    #[must_use]
620    pub const fn consecutive_offline(&self) -> u32 {
621        self.consecutive_offline
622    }
623
624    /// How long GitHub has been unreachable, or `None` when it is not.
625    ///
626    /// Measured from the first poll of the current run rather than inferred
627    /// from [`Self::consecutive_offline`] times the interval. The two diverge
628    /// as soon as the back-off starts doubling, and this is the number the
629    /// 24-hour queue-cancellation warning is compared against — an estimate
630    /// would make that warning fire early or late, and it is the one thing the
631    /// offline state exists to say.
632    #[must_use]
633    pub fn offline_for(&self, now: Timestamp) -> Option<Duration> {
634        let since = self.offline_since?;
635        now.signed_duration_since(since).to_std().ok()
636    }
637
638    /// The hard floor no computed delay may go below.
639    #[must_use]
640    pub const fn floor() -> Duration {
641        Duration::from_secs(RefreshInterval::MIN_SECS as u64)
642    }
643
644    /// Decide when to poll next, given how this pass ended.
645    ///
646    /// `failure` is the most severe failure across the targets polled this pass,
647    /// or `None` when every target answered. A pass that answered resets the
648    /// offline run, which is the whole of "recovery needs no bookkeeping":
649    /// demand is recomputed from the current queued-run set on every poll, so
650    /// there is nothing else to unwind.
651    pub fn next_poll(
652        &mut self,
653        failure: Option<&RefreshState>,
654        now: Timestamp,
655        jitter: &dyn Jitter,
656    ) -> NextPoll {
657        let nominal = self.nominal();
658
659        let next = match failure {
660            None => {
661                self.recovered();
662                NextPoll {
663                    delay: nominal,
664                    pace: PollPace::Nominal,
665                }
666            }
667            Some(RefreshState::Offline) => {
668                self.consecutive_offline = self.consecutive_offline.saturating_add(1);
669                // The instant the *run* began, not the instant of this poll.
670                self.offline_since.get_or_insert(now);
671                NextPoll {
672                    delay: self.offline_delay(nominal, jitter),
673                    pace: PollPace::Offline {
674                        consecutive: self.consecutive_offline,
675                    },
676                }
677            }
678            Some(state @ RefreshState::RateLimited(limit)) => {
679                self.recovered();
680                NextPoll {
681                    // `max`, never `+`. See the type documentation.
682                    delay: retry_floor(state, now).max(nominal),
683                    pace: PollPace::RateLimited { kind: limit.kind },
684                }
685            }
686            Some(state @ RefreshState::LockedOut { .. }) => {
687                self.recovered();
688                NextPoll {
689                    delay: retry_floor(state, now).max(nominal),
690                    pace: PollPace::LockedOut,
691                }
692            }
693            // Unauthorized, Forbidden, Failed, Cancelled. `retry_delay` is
694            // `None` for all of them, and deliberately: no wait fixes a revoked
695            // credential or a missing grant. Polling stops being useful but
696            // does not stop, because the poll is also how a re-authentication
697            // is noticed.
698            Some(_) => {
699                // GitHub answered, so it is reachable: whatever is wrong, it is
700                // not an outage, and an outage run that was open must close.
701                self.recovered();
702                NextPoll {
703                    delay: nominal,
704                    pace: PollPace::Blocked,
705                }
706            }
707        };
708
709        debug_assert!(
710            next.delay >= Self::floor(),
711            "the 30-second floor is a rate-budget constraint and no branch may go below it"
712        );
713        next
714    }
715
716    /// GitHub answered something. Whatever it was, the outage run is over.
717    fn recovered(&mut self) {
718        self.consecutive_offline = 0;
719        self.offline_since = None;
720    }
721
722    fn offline_delay(&self, nominal: Duration, jitter: &dyn Jitter) -> Duration {
723        let doublings = self
724            .consecutive_offline
725            .saturating_sub(1)
726            .min(MAX_BACKOFF_DOUBLINGS);
727        let grown = nominal.saturating_mul(1_u32 << doublings);
728        let capped = grown.min(MAX_OFFLINE_BACKOFF);
729        // Additive, never subtractive: see `JITTER_RATIO`. The result may exceed
730        // `MAX_OFFLINE_BACKOFF` by up to the jitter ratio, which is the price of
731        // keeping a fleet of agents from retrying in lockstep at the plateau —
732        // a cap applied *after* jitter would collapse every agent onto the same
733        // instant precisely when the outage is longest.
734        let spread = capped.mul_f64(JITTER_RATIO * jitter.fraction().clamp(0.0, 1.0));
735        capped.saturating_add(spread).max(Self::floor())
736    }
737}
738
739/// `c3`'s retry floor, with the one fallback this loop needs.
740///
741/// [`RefreshState::retry_delay`] answers `None` for the states no wait fixes,
742/// and those never reach here — the caller matches them into
743/// [`PollPace::Blocked`] first. The fallback exists so that a future
744/// `RefreshState` variant added to the two arms above cannot silently schedule a
745/// zero-second retry against an endpoint that asked for quiet.
746fn retry_floor(state: &RefreshState, now: Timestamp) -> Duration {
747    state.retry_delay(now).unwrap_or(PollSchedule::floor())
748}
749
750// ---------------------------------------------------------------------------
751// Offline
752// ---------------------------------------------------------------------------
753
754/// What an operator is told while GitHub is unreachable.
755///
756/// Flow 3.3 requires four things of an outage — start no new runner, retain
757/// existing runner processes, report `offline`, back off with jitter — and one
758/// thing of the *state*: that it says GitHub cancels queued jobs after 24 hours,
759/// so a prolonged outage loses queued work. That bound is stated here rather
760/// than left for a reader to infer, because an operator who does not know it has
761/// no reason to treat a long outage as urgent.
762#[derive(Debug, Clone, Copy, PartialEq, Eq)]
763pub struct OfflineState {
764    /// The unbroken run of failed polls.
765    pub consecutive: u32,
766    /// How long until the next attempt.
767    pub retry_in: Duration,
768    /// How long this loop has been unable to reach GitHub, when it is known.
769    pub offline_for: Option<Duration>,
770}
771
772impl OfflineState {
773    #[must_use]
774    pub const fn new(consecutive: u32, retry_in: Duration) -> Self {
775        Self {
776            consecutive,
777            retry_in,
778            offline_for: None,
779        }
780    }
781
782    #[must_use]
783    pub const fn since(mut self, offline_for: Duration) -> Self {
784        self.offline_for = Some(offline_for);
785        self
786    }
787
788    /// Whether the outage has already outlasted GitHub's queue.
789    ///
790    /// `false` when the duration is unknown: this reports a fact, and "we cannot
791    /// tell" is not the same fact as "not yet".
792    #[must_use]
793    pub fn has_outlasted_the_queue(&self) -> bool {
794        self.offline_for
795            .is_some_and(|elapsed| elapsed >= GITHUB_CANCELS_QUEUED_JOBS_AFTER)
796    }
797}
798
799impl fmt::Display for OfflineState {
800    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
801        write!(
802            f,
803            "GitHub is unreachable; no new runners are being started and running \
804             runners are left alone. Retrying in {}s",
805            self.retry_in.as_secs()
806        )?;
807        if self.has_outlasted_the_queue() {
808            f.write_str(
809                ". This outage has lasted more than 24 hours, and GitHub cancels a queued \
810                 job after 24 hours, so queued work has been lost",
811            )
812        } else {
813            f.write_str(
814                ". GitHub cancels a queued job after 24 hours, so an outage longer than \
815                 that loses queued work",
816            )
817        }
818    }
819}
820
821// ---------------------------------------------------------------------------
822// The launcher port
823// ---------------------------------------------------------------------------
824
825/// What this loop asks `e3` to create.
826#[derive(Debug, Clone, Copy)]
827pub struct LaunchRequest<'a> {
828    pub host: &'a Host,
829    pub policy: &'a ScalePolicy,
830    /// Proof that e1 still owns the host allocation lock for every package,
831    /// prune, and process-start effect performed by e3.
832    // Crate-visible so only this allocator can mint the request that reaches
833    // package pruning. A caller holding an unrelated public AllocationLock can
834    // no longer assemble a LaunchRequest and present that guard as authority.
835    pub(crate) allocation_guard: &'a AllocationGuard,
836    /// How long this loop waited for that lock, for the launch's timings.
837    pub lock_wait: Duration,
838}
839
840/// Why one runner could not be started.
841///
842/// Carries `b1`'s [`FailureReason`] rather than a taxonomy of this module's own:
843/// the reasons a runner fails to start are `e3`'s to know and `b1`'s to name,
844/// and a third vocabulary here would be a third answer to a question the
845/// operator asks once.
846#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
847#[error("the runner could not be started: {reason}")]
848pub struct LaunchFailure {
849    pub reason: FailureReason,
850}
851
852/// A lifecycle conclusion that must return through ordinary demand and
853/// capacity allocation before another runner may start.
854#[derive(Debug, Clone, Copy, PartialEq, Eq)]
855pub struct ReplacementIntent {
856    pub policy: PolicyId,
857    pub previous_attempt: AttemptId,
858    pub operation: &'static str,
859}
860
861impl LaunchFailure {
862    #[must_use]
863    pub const fn new(reason: FailureReason) -> Self {
864        Self { reason }
865    }
866}
867
868/// The seam between the decision to start a runner and the act of starting one.
869///
870/// `e3` implements this; every test in this file fakes it, which is what makes
871/// the whole allocator path decidable with no process, no filesystem and no
872/// network.
873///
874/// # Why the attempt set comes through the same port as the launch
875///
876/// `b1` makes this argument at [`HostAllocator::from_attempts`] and it applies
877/// one layer up: the host-wide total (D9) and every per-policy count (D7) are
878/// two questions asked of **one** set, and a design that let the caller supply
879/// the set separately from the thing that creates its members is a design in
880/// which the two can disagree. Worse, it makes `&[]` expressible — and an empty
881/// attempt set is exactly the shape that drops the `- active_owned_runners`
882/// term, starts a second runner for a job already being served, and reports no
883/// error while doing it.
884///
885/// So the launcher is asked, under the allocation lock, immediately before each
886/// runtime is created. There is no second supply point and no cached copy.
887///
888/// # The two ways an implementer can say "I hold no attempts"
889///
890/// The argument above closes the hole for a *caller*. It stayed open one level
891/// down for the **implementer**, in two shapes that both oversubscribe the
892/// machine and neither of which reports anything:
893///
894/// * **By failing.** `attempts()` used to be infallible, which left `e3` — which
895///   reads a journal off a disk — a choice between panicking and answering
896///   `vec![]` on an I/O error. An empty set is indistinguishable from an idle
897///   host, so a transient read failure reads as "nothing is running" and the
898///   next pass allocates the whole machine for jobs already being served. It is
899///   fallible now, and [`Reconciler`] treats a failure the way it treats a lock
900///   it could not take: start nothing, say so, try again next pass.
901/// * **By lagging.** [`Self::launch`] returns the attempt it created rather than
902///   its identifier, so the caller can carry it. See that method for the
903///   measurement that made this necessary.
904#[async_trait::async_trait]
905pub trait RunnerLauncher: fmt::Debug + Send + Sync {
906    /// Reconcile this policy's existing processes before demand is read and
907    /// capacity is recomputed. A concluded pre-acceptance attempt thereby
908    /// becomes an ordinary allocation candidate in this same pass; replacement
909    /// never bypasses the allocator.
910    async fn supervise(
911        &self,
912        _policy: &ScalePolicy,
913    ) -> Result<Vec<ReplacementIntent>, LaunchFailure> {
914        Ok(Vec::new())
915    }
916
917    /// Every attempt this host holds, across every policy, terminal ones
918    /// included.
919    ///
920    /// Terminal attempts are included rather than filtered out because the
921    /// caller needs both answers from one set:
922    /// [`AttemptState::counts_against_capacity`] decides the ceiling, and the
923    /// terminal ones are what [`RunnerLauncher::clean`] is for.
924    ///
925    /// # Errors
926    /// [`LaunchFailure`] when the set could not be read. **Never answer `Ok`
927    /// with an empty vector to signal a failure** — the caller cannot tell that
928    /// from an idle host, and the two lead to opposite actions.
929    async fn attempts(&self) -> Result<Vec<RunnerAttempt>, LaunchFailure>;
930
931    /// Create exactly one runtime and start one runner, and return the attempt
932    /// that now exists.
933    ///
934    /// Called once per grant, with the host-wide allocation lock held.
935    ///
936    /// # The attempt is returned, not just its identifier
937    ///
938    /// The host ceiling is enforced against a host-wide total, and that total is
939    /// recomputed from [`Self::attempts`] on every hold. If a launch is not yet
940    /// visible there when the *next* policy is allocated for — a journal write
941    /// that has not landed, an asynchronous store, a cache — then that policy's
942    /// grant is computed from a set missing the previous policy's runners, and
943    /// it is too large.
944    ///
945    /// That is measured, not hypothetical. With a launcher whose attempts never
946    /// became visible, two policies on a host of **three** started **six**
947    /// runners, with the allocation lock held correctly throughout:
948    /// `host_capacity=3, started=6, launches=6`. Serialisation was never the
949    /// problem; the arithmetic under it was reading a stale set.
950    ///
951    /// So an implementer *should* make the new attempt visible to
952    /// [`Self::attempts`] before returning — and the caller does not depend on
953    /// it. [`Reconciler`] carries what this pass created and merges it, by
954    /// [`RunnerAttempt::id`], with whatever the launcher reports. A launcher
955    /// that honours the contract is not double-counted, and one that lags cannot
956    /// oversubscribe the host.
957    ///
958    /// # Every call must return a **fresh** [`RunnerAttempt::id`]
959    ///
960    /// This is a requirement, not a convention, because the merge above is what
961    /// carries the host ceiling and the merge is keyed on the identifier. Two
962    /// calls that answer with the same id are two runtimes that the host-wide
963    /// total counts once, and the machine is then allocated past
964    /// `host_capacity`: probed at `host_capacity = 3` with one slot already
965    /// busy and a launcher answering with a duplicate id, the pass started
966    /// **four** runners for five occupied slots.
967    ///
968    /// That is a narrower defect than the lagging launcher above — that one
969    /// needed no bug at all, this one needs a broken id generator — but `e3` is
970    /// the implementor and cannot honour a requirement nobody states.
971    /// [`Reconciler::host_attempts`] carries a `debug_assert` that fires on a
972    /// collision, so a development build finds it at the first duplicate rather
973    /// than through an oversubscribed host.
974    ///
975    /// # Errors
976    /// [`LaunchFailure`], carrying the [`FailureReason`] `e3` recorded.
977    async fn launch(&self, request: LaunchRequest<'_>) -> Result<RunnerAttempt, LaunchFailure>;
978
979    /// Remove a terminal attempt's runtime and mark it `cleaned`.
980    ///
981    /// Never called for a non-terminal attempt: capacity is reclaimed when an
982    /// attempt reaches a terminal state and at no other time.
983    ///
984    /// # Errors
985    /// [`LaunchFailure`], carrying the [`FailureReason`] `e3` recorded.
986    async fn clean(&self, attempt: AttemptId) -> Result<(), LaunchFailure>;
987}
988
989/// A [`RunnerLauncher`] that refuses every launch with
990/// [`FailureReason::HostUnfit`] while `unfit` is set, and passes everything
991/// else straight through.
992///
993/// The daemon sets the flag while a required host check fails (`host
994/// doctor`). The refusal comes before the inner launcher runs, so nothing is
995/// registered with GitHub, and the reconciler reports it as a
996/// `runner_start_failed` event with reason `host_unfit` -- visible, rather than
997/// a host that silently stopped serving. Supervision, the attempt set and
998/// cleanup are never gated: what already runs is still looked after.
999#[derive(Debug)]
1000pub struct FitnessGatedLauncher {
1001    inner: Arc<dyn RunnerLauncher>,
1002    unfit: Arc<AtomicBool>,
1003}
1004
1005impl FitnessGatedLauncher {
1006    #[must_use]
1007    pub const fn new(inner: Arc<dyn RunnerLauncher>, unfit: Arc<AtomicBool>) -> Self {
1008        Self { inner, unfit }
1009    }
1010}
1011
1012#[async_trait::async_trait]
1013impl RunnerLauncher for FitnessGatedLauncher {
1014    async fn supervise(
1015        &self,
1016        policy: &ScalePolicy,
1017    ) -> Result<Vec<ReplacementIntent>, LaunchFailure> {
1018        self.inner.supervise(policy).await
1019    }
1020
1021    async fn attempts(&self) -> Result<Vec<RunnerAttempt>, LaunchFailure> {
1022        self.inner.attempts().await
1023    }
1024
1025    async fn launch(&self, request: LaunchRequest<'_>) -> Result<RunnerAttempt, LaunchFailure> {
1026        if self.unfit.load(Ordering::Acquire) {
1027            return Err(LaunchFailure::new(FailureReason::HostUnfit));
1028        }
1029        self.inner.launch(request).await
1030    }
1031
1032    async fn clean(&self, attempt: AttemptId) -> Result<(), LaunchFailure> {
1033        self.inner.clean(attempt).await
1034    }
1035}
1036
1037// ---------------------------------------------------------------------------
1038// The host-wide allocation lock
1039// ---------------------------------------------------------------------------
1040
1041/// The lock is held for as long as this value lives.
1042///
1043/// Opaque on purpose: what is being held differs between the in-process and the
1044/// file-backed implementation, and a caller that could see which one it has
1045/// would eventually branch on it.
1046pub struct AllocationGuard {
1047    _held: Box<dyn std::any::Any + Send + Sync>,
1048}
1049
1050impl AllocationGuard {
1051    /// Wrap whatever the implementation holds. Dropping the guard drops it.
1052    #[must_use]
1053    fn new<T: Send + Sync + 'static>(held: T) -> Self {
1054        Self {
1055            _held: Box::new(held),
1056        }
1057    }
1058}
1059
1060impl fmt::Debug for AllocationGuard {
1061    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1062        f.write_str("AllocationGuard")
1063    }
1064}
1065
1066/// The host-wide allocation lock could not be taken.
1067#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1068#[error("the host-wide allocation lock is held by another allocator; no runtime was created")]
1069pub struct AllocationLockBusy;
1070
1071/// Flow 2.4's *"takes the host-wide allocation lock before creating each local
1072/// runtime"*, as a port.
1073///
1074/// # Why a lock is needed at all, given the allocator already exists
1075///
1076/// [`HostAllocator`] enforces D9 across the policies of **one** pass. It cannot
1077/// enforce anything across two passes running at once, and `f3` runs one
1078/// demand-polling loop per target: without serialisation, two loops read the
1079/// same headroom, each finds it sufficient, and the host ends up with the sum of
1080/// two grants it only ever had room for one of. The lock is what makes the
1081/// read-decide-create sequence atomic, and it is taken once per runtime rather
1082/// than once per pass so that a slow package download in one policy does not
1083/// hold the whole host still.
1084#[async_trait::async_trait]
1085pub trait AllocationLock: fmt::Debug + Send + Sync {
1086    /// Take the lock, waiting briefly for it.
1087    ///
1088    /// # Errors
1089    /// [`AllocationLockBusy`] when it could not be taken. A refused grant is
1090    /// always safe: the next pass re-reads the headroom and tries again.
1091    async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy>;
1092}
1093
1094/// The lock every task inside one agent process contends for.
1095///
1096/// This is the implementation that matters in practice, because the
1097/// single-instance lock (`d1`) already guarantees one agent per host: the
1098/// concurrency the allocation lock actually has to serialise is `f3`'s
1099/// per-target loops inside that one process. A `tokio` mutex rather than a
1100/// `std` one because it is held across the `await` that creates the runtime.
1101#[derive(Debug, Default)]
1102pub struct InProcessAllocationLock {
1103    mutex: Arc<tokio::sync::Mutex<()>>,
1104}
1105
1106impl InProcessAllocationLock {
1107    #[must_use]
1108    pub fn new() -> Self {
1109        Self::default()
1110    }
1111}
1112
1113#[async_trait::async_trait]
1114impl AllocationLock for InProcessAllocationLock {
1115    async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
1116        let mutex = Arc::clone(&self.mutex);
1117        let guard = mutex.lock_owned().await;
1118        Ok(AllocationGuard::new(guard))
1119    }
1120}
1121
1122/// `d1`'s file lock, which is host-wide across processes as well as across
1123/// tasks.
1124///
1125/// Defence in depth behind [`InProcessAllocationLock`], for the configuration
1126/// `d1` documents as the one where two agents can genuinely coexist: the
1127/// platform state directory is per-account, so a service-account daemon and an
1128/// interactive `daemon run` resolve different paths and do not contend for the
1129/// single-instance lock. They do contend here if they share a state directory.
1130///
1131/// [`runner_manager_platform::lock::HostLock::acquire`] blocks the calling
1132/// thread and its own documentation names this caller: *"Async callers must wrap
1133/// it in [`tokio::task::spawn_blocking`]"*. That is what this does, and the
1134/// returned `HostLock` lives inside the guard, because dropping it is the
1135/// release.
1136#[derive(Debug, Clone)]
1137pub struct FileAllocationLock {
1138    paths: Arc<runner_manager_platform::paths::AppPaths>,
1139    wait: Duration,
1140}
1141
1142impl FileAllocationLock {
1143    #[must_use]
1144    pub const fn new(paths: Arc<runner_manager_platform::paths::AppPaths>) -> Self {
1145        Self {
1146            paths,
1147            wait: ALLOCATION_LOCK_WAIT,
1148        }
1149    }
1150
1151    #[must_use]
1152    pub const fn with_wait(mut self, wait: Duration) -> Self {
1153        self.wait = wait;
1154        self
1155    }
1156}
1157
1158#[async_trait::async_trait]
1159impl AllocationLock for FileAllocationLock {
1160    async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
1161        use runner_manager_platform::lock::{HostLock, LockKind};
1162
1163        let paths = Arc::clone(&self.paths);
1164        let wait = self.wait;
1165        let held = tokio::task::spawn_blocking(move || {
1166            HostLock::acquire(&paths, LockKind::Allocation, wait)
1167        })
1168        .await;
1169
1170        match held {
1171            Ok(Ok(lock)) => Ok(AllocationGuard::new(lock)),
1172            // A refused lock and a panicked blocking task are the same outcome
1173            // to this caller: no runtime was created and the next pass will
1174            // re-read the headroom. Neither is allowed to look like a grant.
1175            Ok(Err(_)) | Err(_) => Err(AllocationLockBusy),
1176        }
1177    }
1178}
1179
1180/// Adds the Windows/WSL recovery fence to an ordinary allocation lock.
1181///
1182/// A Windows watchdog writes its drain request before attempting the same
1183/// atomic directory claim. Therefore either this guard owns the directory and
1184/// finishes the one launch already in progress, or Windows owns it and no new
1185/// launch can pass. A malformed configured fence fails closed.
1186#[derive(Debug)]
1187pub struct WslRecoveryAllocationLock {
1188    paths: Arc<runner_manager_platform::paths::AppPaths>,
1189    inner: Arc<dyn AllocationLock>,
1190}
1191
1192impl WslRecoveryAllocationLock {
1193    #[must_use]
1194    pub fn new(
1195        paths: Arc<runner_manager_platform::paths::AppPaths>,
1196        inner: Arc<dyn AllocationLock>,
1197    ) -> Self {
1198        Self { paths, inner }
1199    }
1200}
1201
1202#[async_trait::async_trait]
1203impl AllocationLock for WslRecoveryAllocationLock {
1204    async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
1205        use runner_manager_platform::wsl::fence::{
1206            DrainRequest, FenceClaim, FenceOwnerKind, GuestRecoveryConfig,
1207        };
1208
1209        let paths = Arc::clone(&self.paths);
1210        let claim = tokio::task::spawn_blocking(move || {
1211            let Some(config) = GuestRecoveryConfig::read(&paths).map_err(|_| ())? else {
1212                return Ok(None);
1213            };
1214            let Some(claim) =
1215                FenceClaim::try_claim(&config.shared_root, FenceOwnerKind::GuestLaunch, None)
1216                    .map_err(|_| ())?
1217            else {
1218                return Err(());
1219            };
1220            match DrainRequest::read(&config.shared_root) {
1221                Ok(None) => Ok(Some(claim)),
1222                Ok(Some(_)) | Err(_) => Err(()),
1223            }
1224        })
1225        .await
1226        .map_err(|_| AllocationLockBusy)?
1227        .map_err(|()| AllocationLockBusy)?;
1228
1229        let inner = self.inner.acquire().await?;
1230        Ok(AllocationGuard::new((claim, inner)))
1231    }
1232}
1233
1234// ---------------------------------------------------------------------------
1235// Lifecycle events
1236// ---------------------------------------------------------------------------
1237
1238/// Which terminal thing happened, as a closed vocabulary.
1239///
1240/// The distinction `g2` renders: an idle exit is the accepted surplus case and
1241/// **not** a failure, and showing it as one sends an operator hunting a fault
1242/// that does not exist.
1243#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1244pub enum OutcomeKind {
1245    CompletedJob,
1246    IdleExit,
1247    Failed,
1248    Orphaned,
1249}
1250
1251impl OutcomeKind {
1252    #[must_use]
1253    pub const fn of(outcome: &AttemptOutcome) -> Self {
1254        match outcome {
1255            AttemptOutcome::CompletedJob => Self::CompletedJob,
1256            AttemptOutcome::ExitedIdleWithoutWork => Self::IdleExit,
1257            AttemptOutcome::Failed { .. } => Self::Failed,
1258            AttemptOutcome::Orphaned => Self::Orphaned,
1259        }
1260    }
1261
1262    #[must_use]
1263    pub const fn is_failure(&self) -> bool {
1264        matches!(self, Self::Failed | Self::Orphaned)
1265    }
1266
1267    #[must_use]
1268    pub const fn as_str(&self) -> &'static str {
1269        match self {
1270            Self::CompletedJob => "completed_job",
1271            Self::IdleExit => "exited_idle_without_work",
1272            Self::Failed => "failed",
1273            Self::Orphaned => "orphaned",
1274        }
1275    }
1276}
1277
1278impl fmt::Display for OutcomeKind {
1279    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1280        f.write_str(self.as_str())
1281    }
1282}
1283
1284/// A [`FailureReason`]'s variant name, with no detail.
1285///
1286/// [`FailureReason::Other`] carries a `String` that `e3` fills in, and an event
1287/// is not the place for it: `07-security.md`'s log scan runs over everything
1288/// this loop emits, and free text is the one shape that can carry a credential
1289/// past a field allow-list. The operator-facing detail reaches the journal
1290/// through `b2` and the screen through `g2`; what reaches an *event* is the
1291/// variant.
1292#[must_use]
1293pub const fn failure_reason_kind(reason: &FailureReason) -> &'static str {
1294    match reason {
1295        FailureReason::JitRequestFailed => "jit_request_failed",
1296        FailureReason::JitExpired => "jit_expired",
1297        FailureReason::RunnerPackageUnverified => "runner_package_unverified",
1298        FailureReason::RunnerVersionRejected => "runner_version_rejected",
1299        FailureReason::ProcessStartFailed => "process_start_failed",
1300        FailureReason::ProcessExitedUnexpectedly => "process_exited_unexpectedly",
1301        FailureReason::RegistrationTimedOut => "registration_timed_out",
1302        FailureReason::TerminatedAfterRegistrationTimeout => {
1303            "terminated_after_registration_timeout"
1304        }
1305        FailureReason::IsolationProvider(category) => match category {
1306            IsolationProviderFailure::Unsupported => "isolation_provider_unsupported",
1307            IsolationProviderFailure::NotInstalled => "isolation_provider_not_installed",
1308            IsolationProviderFailure::PermissionDenied => "isolation_provider_permission_denied",
1309            IsolationProviderFailure::ImageUnavailableOrIncompatible => {
1310                "isolation_image_unavailable"
1311            }
1312            IsolationProviderFailure::DiskQuotaUnavailable => "isolation_disk_quota_unavailable",
1313            IsolationProviderFailure::Degraded => "isolation_provider_degraded",
1314            IsolationProviderFailure::RuntimeOperationFailed => {
1315                "isolation_runtime_operation_failed"
1316            }
1317            IsolationProviderFailure::OwnershipMismatch => "isolation_ownership_mismatch",
1318            IsolationProviderFailure::UnsafeRuntimePath => "isolation_unsafe_runtime_path",
1319            IsolationProviderFailure::JitHandoffRejected => "isolation_jit_handoff_rejected",
1320        },
1321        FailureReason::WorkspaceCleanupDeferred => "ephemeral_workspace_could_not_be_removed",
1322        FailureReason::HostUnfit => "host_unfit",
1323        FailureReason::Other(_) => "other",
1324    }
1325}
1326
1327/// Fixed, credential-free reasons a queued job cannot route to one profile.
1328#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
1329pub enum RoutingRefusal {
1330    OverlappingProfiles,
1331}
1332
1333impl RoutingRefusal {
1334    #[must_use]
1335    pub const fn as_str(self) -> &'static str {
1336        match self {
1337            Self::OverlappingProfiles => "overlapping_profile_matches",
1338        }
1339    }
1340}
1341
1342impl fmt::Display for RoutingRefusal {
1343    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1344        f.write_str(match self {
1345            Self::OverlappingProfiles => {
1346                "repair overlapping profile labels or require a unique profile selector"
1347            }
1348        })
1349    }
1350}
1351
1352/// What `g2`'s activity view and the local log sink see.
1353///
1354/// **Every field is an identifier, a count, a duration, or a `&'static str`
1355/// drawn from a closed set.** There is no `String` anywhere in this enum, which
1356/// is what makes "no emitted event contains a token, a JIT blob, or a credential
1357/// header" a property of the type rather than a discipline each call site has to
1358/// keep. `tests::no_emitted_event_can_carry_a_credential` renders every variant
1359/// through `d1`'s scrubber and asserts nothing changes, with a positive control
1360/// so the assertion cannot pass vacuously.
1361#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1362pub enum LifecycleEvent {
1363    /// A demand poll answered for one target.
1364    DemandObserved {
1365        policy: PolicyId,
1366        /// Queued jobs this policy's routing labels match. The number clamped.
1367        demand: u32,
1368        /// Queued jobs whose required labels this policy does not carry.
1369        ///
1370        /// Never demand. Reported because the difference between this and
1371        /// `demand` is the whole value of the label filtering, and an operator
1372        /// wondering why a busy repository started no runners is owed it.
1373        not_matched: u32,
1374        /// Queued jobs whose `runs-on` could not be resolved statically.
1375        ///
1376        /// `b1` requires these be "reported as unresolvable rather than silently
1377        /// counted or silently dropped": counting one would start a runner for a
1378        /// job that may not be ours, and dropping it would hide a workflow this
1379        /// host can never serve. A count rather than the reasons themselves
1380        /// because this type is `Copy`, and `c4` logs the reasons where it
1381        /// builds them.
1382        unresolvable: u32,
1383        /// `false` when the count is a floor rather than a total.
1384        complete: bool,
1385    },
1386    /// A queued job cannot be assigned unambiguously to this target's profiles.
1387    RoutingRefused {
1388        policy: PolicyId,
1389        reason: RoutingRefusal,
1390        count: u32,
1391    },
1392    /// A target could not be polled, so its policies start nothing this pass.
1393    TargetUnreadable {
1394        policy: PolicyId,
1395        reason: &'static str,
1396    },
1397    /// One policy's share of the pass.
1398    Allocated {
1399        policy: PolicyId,
1400        demand: u32,
1401        desired: u16,
1402        active_owned: u16,
1403        headroom: u16,
1404        to_start: u16,
1405        limiting: LimitingFactor,
1406    },
1407    /// A monitor-only policy was skipped entirely, before any demand request
1408    /// was issued for it (D19).
1409    MonitorOnlySkipped { policy: PolicyId },
1410    /// One runtime was created and one runner started.
1411    RunnerStarted {
1412        policy: PolicyId,
1413        attempt: AttemptId,
1414    },
1415    /// One runner could not be started.
1416    RunnerStartFailed {
1417        policy: PolicyId,
1418        reason: &'static str,
1419    },
1420    /// The allocation lock was not free, so `count` runners this policy was
1421    /// granted were not created this pass.
1422    AllocationDeferred { policy: PolicyId, count: u16 },
1423    /// The host's attempt set could not be read at all.
1424    ///
1425    /// Distinct from an empty set on purpose, and the whole reason
1426    /// [`RunnerLauncher::attempts`] is fallible: the two produce the same
1427    /// *number* and demand opposite actions.
1428    AttemptsUnreadable { reason: &'static str },
1429    /// A terminal attempt's runtime was removed.
1430    AttemptCleaned {
1431        policy: PolicyId,
1432        attempt: AttemptId,
1433        outcome: OutcomeKind,
1434    },
1435    /// A terminal attempt's runtime could not be removed. It will be retried on
1436    /// the next pass, and this is what keeps that retry from being silent.
1437    AttemptCleanFailed {
1438        policy: PolicyId,
1439        attempt: AttemptId,
1440        reason: &'static str,
1441    },
1442    /// Scale-down declined to remove a runner that is executing a job.
1443    ScaleDownRefused {
1444        policy: PolicyId,
1445        attempt: AttemptId,
1446    },
1447    /// When the next poll is, and why then.
1448    PollScheduled { retry_in_ms: u64, pace: PollPace },
1449}
1450
1451impl LifecycleEvent {
1452    /// A fixed name, for the `event` field `d1`'s sink allows verbatim.
1453    #[must_use]
1454    pub const fn name(&self) -> &'static str {
1455        match self {
1456            Self::DemandObserved { .. } => "demand_observed",
1457            Self::RoutingRefused { .. } => "routing_refused",
1458            Self::TargetUnreadable { .. } => "target_unreadable",
1459            Self::Allocated { .. } => "allocated",
1460            Self::MonitorOnlySkipped { .. } => "monitor_only_skipped",
1461            Self::RunnerStarted { .. } => "runner_started",
1462            Self::RunnerStartFailed { .. } => "runner_start_failed",
1463            Self::AllocationDeferred { .. } => "allocation_deferred",
1464            Self::AttemptsUnreadable { .. } => "attempts_unreadable",
1465            Self::AttemptCleaned { .. } => "attempt_cleaned",
1466            Self::AttemptCleanFailed { .. } => "attempt_clean_failed",
1467            Self::ScaleDownRefused { .. } => "scale_down_refused",
1468            Self::PollScheduled { .. } => "poll_scheduled",
1469        }
1470    }
1471
1472    /// Which policy this event is about.
1473    #[must_use]
1474    pub const fn policy(&self) -> Option<PolicyId> {
1475        match self {
1476            Self::DemandObserved { policy, .. }
1477            | Self::RoutingRefused { policy, .. }
1478            | Self::TargetUnreadable { policy, .. }
1479            | Self::Allocated { policy, .. }
1480            | Self::MonitorOnlySkipped { policy }
1481            | Self::RunnerStarted { policy, .. }
1482            | Self::RunnerStartFailed { policy, .. }
1483            | Self::AllocationDeferred { policy, .. }
1484            | Self::AttemptCleaned { policy, .. }
1485            | Self::AttemptCleanFailed { policy, .. }
1486            | Self::ScaleDownRefused { policy, .. } => Some(*policy),
1487            Self::PollScheduled { .. } | Self::AttemptsUnreadable { .. } => None,
1488        }
1489    }
1490}
1491
1492impl fmt::Display for LifecycleEvent {
1493    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1494        match self {
1495            Self::DemandObserved {
1496                policy,
1497                demand,
1498                not_matched,
1499                unresolvable,
1500                complete,
1501            } => write!(
1502                f,
1503                "policy {policy}: {demand} queued jobs for this host{}{}{}",
1504                if *not_matched == 0 {
1505                    String::new()
1506                } else {
1507                    format!(", {not_matched} for other labels")
1508                },
1509                if *unresolvable == 0 {
1510                    String::new()
1511                } else {
1512                    format!(", {unresolvable} with an unresolvable `runs-on`")
1513                },
1514                if *complete {
1515                    ""
1516                } else {
1517                    " (a floor, not a total)"
1518                }
1519            ),
1520            Self::TargetUnreadable { policy, reason } => {
1521                write!(f, "policy {policy}: target unreadable ({reason})")
1522            }
1523            Self::RoutingRefused {
1524                policy,
1525                reason,
1526                count,
1527            } => write!(f, "policy {policy}: {count} queued jobs blocked: {reason}"),
1528            Self::Allocated {
1529                policy,
1530                demand,
1531                desired,
1532                active_owned,
1533                headroom,
1534                to_start,
1535                limiting,
1536            } => write!(
1537                f,
1538                "policy {policy}: demand {demand}, desired {desired}, {active_owned} in \
1539                 flight, {headroom} free on this host, starting {to_start} ({limiting})"
1540            ),
1541            Self::MonitorOnlySkipped { policy } => {
1542                write!(f, "policy {policy}: monitor-only, skipped")
1543            }
1544            Self::RunnerStarted { policy, attempt } => {
1545                write!(f, "policy {policy}: started attempt {attempt}")
1546            }
1547            Self::RunnerStartFailed { policy, reason } => {
1548                write!(f, "policy {policy}: could not start a runner ({reason})")
1549            }
1550            Self::AllocationDeferred { policy, count } => write!(
1551                f,
1552                "policy {policy}: the allocation lock was held; {count} granted runners \
1553                 were not created"
1554            ),
1555            Self::AttemptsUnreadable { reason } => write!(
1556                f,
1557                "the host's attempt set could not be read ({reason}); nothing was started, \
1558                 and this is not the same as the host being idle"
1559            ),
1560            Self::AttemptCleaned {
1561                policy,
1562                attempt,
1563                outcome,
1564            } => write!(f, "policy {policy}: cleaned attempt {attempt} ({outcome})"),
1565            Self::AttemptCleanFailed {
1566                policy,
1567                attempt,
1568                reason,
1569            } => write!(
1570                f,
1571                "policy {policy}: attempt {attempt} could not be cleaned ({reason}); it \
1572                 will be retried"
1573            ),
1574            Self::ScaleDownRefused { policy, attempt } => write!(
1575                f,
1576                "policy {policy}: attempt {attempt} is executing a job and was not removed"
1577            ),
1578            Self::PollScheduled { retry_in_ms, pace } => {
1579                write!(f, "next poll in {retry_in_ms}ms ({pace})")
1580            }
1581        }
1582    }
1583}
1584
1585/// Where lifecycle events go.
1586pub trait EventSink: fmt::Debug + Send + Sync {
1587    fn emit(&self, event: LifecycleEvent);
1588}
1589
1590/// Discards everything. For callers that only want the report.
1591#[derive(Debug, Clone, Copy, Default)]
1592pub struct NoEvents;
1593
1594impl EventSink for NoEvents {
1595    fn emit(&self, _event: LifecycleEvent) {}
1596}
1597
1598/// The local log sink, through `d1`'s redacting layer.
1599///
1600/// Every field name below is on
1601/// [`runner_manager_platform::logging::ALLOWED_FIELDS`]; anything else would be
1602/// replaced with `[redacted]` and the line would lose its meaning rather than
1603/// its safety. `tests::every_field_name_this_sink_emits_is_one_d1_allows` keeps
1604/// that true.
1605#[derive(Debug, Clone, Copy, Default)]
1606pub struct TracingEvents;
1607
1608impl EventSink for TracingEvents {
1609    fn emit(&self, event: LifecycleEvent) {
1610        let name = event.name();
1611        match event {
1612            LifecycleEvent::DemandObserved {
1613                policy,
1614                demand,
1615                not_matched,
1616                unresolvable,
1617                complete,
1618            } => {
1619                tracing::info!(
1620                    event = name,
1621                    policy_id = %policy,
1622                    demand,
1623                    not_matched,
1624                    unresolvable,
1625                    count = u64::from(complete),
1626                );
1627                // There is deliberately no `warn!` here for the "demand is zero
1628                // but jobs were not matched" shape, though it is the one this
1629                // change introduced: before demand was filtered, a repository
1630                // with work in it always produced some, and now a policy whose
1631                // labels do not cover its jobs produces none.
1632                //
1633                // The reason is that the shape is indistinguishable from a
1634                // healthy one. A repository served by a Windows host and a macOS
1635                // host has the other host's jobs queued in it constantly, so
1636                // each agent would warn on every poll about work that is being
1637                // served correctly by the other machine. Telling the two apart
1638                // needs to know whether this policy has *ever* matched anything,
1639                // which is state across polls that this loop does not keep.
1640                //
1641                // What an operator gets instead is the `not_matched` count, on
1642                // this event and in its `Display`, which `g2` renders. "0 queued
1643                // jobs for this host, 5 for other labels" is the diagnosis; a
1644                // warning that fired on every healthy minute would be the kind
1645                // nobody reads.
1646            }
1647            LifecycleEvent::TargetUnreadable { policy, reason } => {
1648                tracing::warn!(event = name, policy_id = %policy, reason);
1649            }
1650            LifecycleEvent::RoutingRefused {
1651                policy,
1652                reason,
1653                count,
1654            } => tracing::warn!(
1655                event = name,
1656                policy_id = %policy,
1657                reason = reason.as_str(),
1658                count,
1659                "{reason}"
1660            ),
1661            LifecycleEvent::Allocated {
1662                policy,
1663                demand,
1664                desired,
1665                active_owned,
1666                headroom,
1667                to_start,
1668                limiting,
1669            } => tracing::info!(
1670                event = name,
1671                policy_id = %policy,
1672                demand,
1673                desired,
1674                capacity = active_owned,
1675                headroom,
1676                count = to_start,
1677                reason = %limiting,
1678            ),
1679            LifecycleEvent::MonitorOnlySkipped { policy } => {
1680                tracing::debug!(event = name, policy_id = %policy, mode = "monitor_only");
1681            }
1682            LifecycleEvent::RunnerStarted { policy, attempt } => {
1683                tracing::info!(event = name, policy_id = %policy, attempt_id = %attempt);
1684            }
1685            LifecycleEvent::RunnerStartFailed { policy, reason } => {
1686                tracing::warn!(event = name, policy_id = %policy, reason);
1687            }
1688            LifecycleEvent::AllocationDeferred { policy, count } => {
1689                // Contention is normally brief, but a configured WSL recovery
1690                // fence whose shared root is unwritable reaches the same
1691                // fail-closed result forever. Demand was already observed and
1692                // capacity was already granted, so hiding this at DEBUG makes
1693                // a host claim healthy while it starts nothing.
1694                tracing::warn!(
1695                    event = name,
1696                    policy_id = %policy,
1697                    reason = "allocation_lock_unavailable",
1698                    count
1699                );
1700            }
1701            LifecycleEvent::AttemptsUnreadable { reason } => {
1702                tracing::warn!(event = name, reason);
1703            }
1704            LifecycleEvent::AttemptCleaned {
1705                policy,
1706                attempt,
1707                outcome,
1708            } => tracing::info!(
1709                event = name,
1710                policy_id = %policy,
1711                attempt_id = %attempt,
1712                outcome = outcome.as_str(),
1713            ),
1714            LifecycleEvent::AttemptCleanFailed {
1715                policy,
1716                attempt,
1717                reason,
1718            } => tracing::warn!(
1719                event = name,
1720                policy_id = %policy,
1721                attempt_id = %attempt,
1722                reason,
1723            ),
1724            LifecycleEvent::ScaleDownRefused { policy, attempt } => tracing::info!(
1725                event = name,
1726                policy_id = %policy,
1727                attempt_id = %attempt,
1728                attempt_state = "busy",
1729            ),
1730            LifecycleEvent::PollScheduled { retry_in_ms, pace } => {
1731                tracing::info!(event = name, retry_in_ms, state = pace.as_str());
1732            }
1733        }
1734    }
1735}
1736
1737/// Keeps every event, in order.
1738///
1739/// `g2`'s activity view is a reader of this, and so is every test below.
1740#[derive(Debug, Default)]
1741pub struct EventLog {
1742    events: Mutex<Vec<LifecycleEvent>>,
1743}
1744
1745impl EventLog {
1746    #[must_use]
1747    pub fn new() -> Self {
1748        Self::default()
1749    }
1750
1751    #[must_use]
1752    pub fn events(&self) -> Vec<LifecycleEvent> {
1753        self.events.lock().map(|e| e.clone()).unwrap_or_default()
1754    }
1755
1756    /// How many events of one name were emitted.
1757    #[must_use]
1758    pub fn count_of(&self, name: &str) -> usize {
1759        self.events()
1760            .iter()
1761            .filter(|event| event.name() == name)
1762            .count()
1763    }
1764}
1765
1766impl EventSink for EventLog {
1767    fn emit(&self, event: LifecycleEvent) {
1768        if let Ok(mut events) = self.events.lock() {
1769            events.push(event);
1770        }
1771    }
1772}
1773
1774/// Both sinks at once: the log sink for the operator's file, the buffer for
1775/// `g2`'s screen.
1776#[derive(Debug)]
1777pub struct TeeEvents(pub Arc<dyn EventSink>, pub Arc<dyn EventSink>);
1778
1779impl EventSink for TeeEvents {
1780    fn emit(&self, event: LifecycleEvent) {
1781        self.0.emit(event);
1782        self.1.emit(event);
1783    }
1784}
1785
1786// ---------------------------------------------------------------------------
1787// The reconciler
1788// ---------------------------------------------------------------------------
1789
1790/// Everything one reconciler needs, written down at the call site.
1791///
1792/// A struct rather than seven positional arguments, for the reason `b1` gives at
1793/// `PersistedAttempt`: several of these are `Arc<dyn …>` and transposing two of
1794/// them type-checks. Construct it with a struct literal so every port is named.
1795pub struct ReconcilerPorts {
1796    pub demand: Arc<dyn DemandSource>,
1797    pub launcher: Arc<dyn RunnerLauncher>,
1798    pub lock: Arc<dyn AllocationLock>,
1799    pub directory: Arc<dyn RepositoryDirectory>,
1800    pub clock: Arc<dyn Clock>,
1801    pub jitter: Arc<dyn Jitter>,
1802    pub events: Arc<dyn EventSink>,
1803}
1804
1805impl fmt::Debug for ReconcilerPorts {
1806    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1807        f.debug_struct("ReconcilerPorts").finish_non_exhaustive()
1808    }
1809}
1810
1811/// What one reconciliation pass did.
1812///
1813/// `started` and the allocations are reported separately on purpose: an
1814/// allocation is what the pass *decided* under the lock, and `started` is what
1815/// actually came up. They differ when a launch fails or when the lock was held,
1816/// and collapsing them would hide both.
1817#[derive(Debug, Clone, Default)]
1818pub struct ReconcileReport {
1819    /// One entry per policy that got as far as being allocated for.
1820    pub allocations: Vec<Allocation>,
1821    /// Policies skipped because they are monitor-only (D19).
1822    pub monitor_only: Vec<PolicyId>,
1823    /// Policies whose target could not be polled this pass.
1824    pub unreadable: Vec<PolicyId>,
1825    /// Policies whose target GitHub actually answered for this pass.
1826    ///
1827    /// The counterpart to [`Self::unreadable`], and the only honest evidence
1828    /// that this host reached GitHub at all. [`Self::allocations`] is not: a
1829    /// policy this host does not own is allocated for with no demand and
1830    /// without any target being polled, so a pass where every poll failed can
1831    /// still end with allocations in it.
1832    pub targets_read: u16,
1833    /// Runners actually started.
1834    pub started: u16,
1835    /// Pre-acceptance attempts routed back through this pass's ordinary
1836    /// demand/capacity decision.
1837    pub replacement_intents: u16,
1838    /// Terminal attempts whose runtime was removed.
1839    pub cleaned: u16,
1840    /// Of those, the surplus case: registered, got no job, exited on its idle
1841    /// timeout. **Not** a failure.
1842    pub idle_exits: u16,
1843    /// Of those, the ones an operator should look at.
1844    pub failures: u16,
1845    /// Runners this pass was granted but did not start because the allocation
1846    /// lock was held.
1847    ///
1848    /// **Grants, not policies.** It used to be incremented once per
1849    /// `start_runners` call that met a held lock, so a policy that launched two
1850    /// of five and then lost the lock reported `1` while three runners went
1851    /// unstarted -- a number that agreed with neither its own name nor its
1852    /// documentation.
1853    pub deferred: u16,
1854    /// Times the host's attempt set could not be read this pass.
1855    ///
1856    /// Non-zero means the pass decided less than it looks like it decided: a
1857    /// policy whose attempt set was unreadable started nothing and is *not* in
1858    /// [`Self::allocations`], because there was no set to compute an allocation
1859    /// from. It is not the same as the host being idle, which is the whole
1860    /// reason [`RunnerLauncher::attempts`] is fallible.
1861    ///
1862    /// **A count, where [`Self::unreadable`] is a `Vec<PolicyId>`, and that
1863    /// asymmetry is deliberate.** An unreadable *target* is a fact about one
1864    /// policy's GitHub target; an unreadable *attempt set* is a fact about this
1865    /// host's journal, which no policy owns — two of the three paths that reach
1866    /// it (`clean_terminal_attempts` and `scale_down`) have no policy in hand at
1867    /// all. Naming policies here would mean either inventing an owner for a
1868    /// host-wide failure or reporting a partial list, and both read as more
1869    /// precision than there is. The pass is distinguishable from an idle one,
1870    /// which is what the field exists for; the per-policy attribution is not
1871    /// available, and is recorded as missing rather than faked.
1872    pub attempts_unreadable: u16,
1873    /// Terminal attempts whose runtime could not be removed. Retried next pass.
1874    pub clean_failures: u16,
1875    /// The most severe failure across the targets polled, when there was one.
1876    pub failure: Option<RefreshState>,
1877    /// What to display while GitHub is unreachable, including how long the
1878    /// outage has run and therefore whether queued work has already been lost.
1879    pub offline: Option<OfflineState>,
1880    /// When to poll next, and why then.
1881    pub next_poll: NextPoll,
1882    /// Demand requests this pass projected against the shared hourly ceiling.
1883    pub demand_requests: u32,
1884}
1885
1886impl ReconcileReport {
1887    /// Whether this pass actually reached GitHub, which is the only thing that
1888    /// entitles it to write a `last GitHub contact`.
1889    ///
1890    /// # Positive evidence, because the absence of a failure is not evidence
1891    ///
1892    /// The record used to be written whenever [`Self::failure`] was `None`, on
1893    /// the belief that an unauthorized target lands in [`Self::unreadable`]
1894    /// rather than in `failure`. **That belief is wrong.** `unreadable` is
1895    /// pushed only from the `PollOutcome::Failed` arm, `failure` is the maximum
1896    /// over every `Failed` reading, and `RefreshState::Unauthorized` scores 2 —
1897    /// so a non-empty `unreadable` always implies `failure.is_some()`, and
1898    /// guarding on both would have changed nothing at all.
1899    ///
1900    /// The path that really writes a contact record without touching GitHub is
1901    /// a pass that polls **nothing**: every policy draining, owned by another
1902    /// host, or monitor-only. `pollable` is then empty, no reading exists, no
1903    /// failure is computed, and the old guard passed. That is how
1904    /// `service status` can answer `healthy` on a host doing nothing at all.
1905    ///
1906    /// So this asks for evidence rather than for the absence of a complaint. A
1907    /// pass with nothing to ask reaches nobody and records nothing, which is
1908    /// what `never` in `service status` is for.
1909    ///
1910    /// Conservative on purpose: `repositories.scope_for` is a real request that
1911    /// can succeed before a demand poll fails, and it is not counted. Contact
1912    /// that cannot be proven is not claimed.
1913    #[must_use]
1914    pub const fn reached_github(&self) -> bool {
1915        self.targets_read > 0
1916    }
1917}
1918
1919impl Default for NextPoll {
1920    fn default() -> Self {
1921        Self {
1922            delay: PollSchedule::floor(),
1923            pace: PollPace::Nominal,
1924        }
1925    }
1926}
1927
1928impl ReconcileReport {
1929    /// Whether GitHub was unreachable this pass.
1930    #[must_use]
1931    pub fn is_offline(&self) -> bool {
1932        matches!(self.failure, Some(RefreshState::Offline))
1933    }
1934
1935    /// The offline state to display, when this pass was one.
1936    #[must_use]
1937    pub const fn offline_state(&self) -> Option<&OfflineState> {
1938        self.offline.as_ref()
1939    }
1940
1941    /// Attempts this pass created. The idle-host assertion reads this.
1942    #[must_use]
1943    pub const fn starts_nothing(&self) -> bool {
1944        self.started == 0
1945    }
1946}
1947
1948/// What one scale-down request did.
1949#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1950pub struct ScaleDownReport {
1951    /// Terminal attempts whose runtime was removed.
1952    pub removed: u16,
1953    /// Attempts executing a job. **Removed nothing, left `busy`.**
1954    pub refused_busy: u16,
1955    /// Terminal attempts whose runtime could not be removed.
1956    pub clean_failures: u16,
1957    /// Live attempts that are not yet busy. Also removed nothing: capacity is
1958    /// reclaimed only when an attempt reaches a terminal state.
1959    pub retained: u16,
1960    /// The host's attempt set could not be read, so **every other field here is
1961    /// meaningless** rather than zero.
1962    ///
1963    /// This is the same distinction [`ReconcileReport::attempts_unreadable`]
1964    /// draws, and it is here for the same reason: a default
1965    /// [`ScaleDownReport`] and a scale-down that could not see the machine are
1966    /// both all-zeros, and they mean opposite things — "there was nothing to
1967    /// reclaim" against "we do not know what there was". Check
1968    /// [`Self::is_conclusive`] before reading a zero as an answer.
1969    pub attempts_unreadable: bool,
1970}
1971
1972impl ScaleDownReport {
1973    /// Whether the counts here describe the machine at all.
1974    ///
1975    /// `false` means the attempt set could not be read, so every zero is
1976    /// "unknown" rather than "none".
1977    #[must_use]
1978    pub const fn is_conclusive(&self) -> bool {
1979        !self.attempts_unreadable
1980    }
1981}
1982
1983/// How long a failed cleanup of one attempt waits before it is tried again,
1984/// doubling per consecutive failure up to [`CLEAN_RETRY_MAXIMUM`].
1985///
1986/// A runtime that cannot be removed used to be retried on every pass, about
1987/// every six seconds, and each failure logs a warning: one stuck Windows
1988/// workspace wrote some 15,000 of them a day. The retry is still real; it is
1989/// just no longer a log flood.
1990const CLEAN_RETRY_INITIAL: Duration = Duration::from_secs(30);
1991
1992/// The longest a failing cleanup waits between tries.
1993const CLEAN_RETRY_MAXIMUM: Duration = Duration::from_secs(30 * 60);
1994
1995/// One attempt's consecutive cleanup failures, and when it may be tried again.
1996#[derive(Debug, Clone, Copy)]
1997struct CleanRetry {
1998    failures: u32,
1999    not_before: Timestamp,
2000}
2001
2002/// Per-attempt cleanup backoff. One each in the [`Reconciler`]'s sweep of
2003/// concluded attempts and in the lifecycle launcher's own supervision pass,
2004/// the two places a failed cleanup is retried.
2005#[derive(Debug, Default)]
2006pub(crate) struct CleanBackoff {
2007    retries: BTreeMap<AttemptId, CleanRetry>,
2008}
2009
2010impl CleanBackoff {
2011    /// The doubling-with-a-ceiling schedule; the attempt bound is unused.
2012    const SCHEDULE: crate::lifecycle::RetryPolicy =
2013        crate::lifecycle::RetryPolicy::bounded(u32::MAX, CLEAN_RETRY_INITIAL, CLEAN_RETRY_MAXIMUM);
2014
2015    /// Whether `attempt`'s cleanup should be tried at `now`.
2016    pub(crate) fn is_due(&self, attempt: AttemptId, now: Timestamp) -> bool {
2017        self.retries
2018            .get(&attempt)
2019            .is_none_or(|retry| now >= retry.not_before)
2020    }
2021
2022    /// Records a failed cleanup and schedules the next try.
2023    pub(crate) fn failed(&mut self, attempt: AttemptId, now: Timestamp) {
2024        let failures = self
2025            .retries
2026            .get(&attempt)
2027            .map_or(1, |retry| retry.failures.saturating_add(1));
2028        let delay = Self::SCHEDULE.delay(failures);
2029        self.retries.insert(
2030            attempt,
2031            CleanRetry {
2032                failures,
2033                // At most `CLEAN_RETRY_MAXIMUM`, so always representable.
2034                not_before: now + chrono::Duration::from_std(delay).unwrap_or_default(),
2035            },
2036        );
2037    }
2038
2039    /// Forgets `attempt`: it was cleaned.
2040    pub(crate) fn succeeded(&mut self, attempt: AttemptId) {
2041        self.retries.remove(&attempt);
2042    }
2043
2044    /// Forgets every attempt not in `present`, so the map cannot outgrow the
2045    /// journal.
2046    fn retain(&mut self, present: &BTreeSet<AttemptId>) {
2047        self.retries.retain(|attempt, _| present.contains(attempt));
2048    }
2049}
2050
2051/// The reconciliation loop.
2052///
2053/// One per target, as `f3` runs them; they share a [`RunnerLauncher`] and an
2054/// [`AllocationLock`], which is what keeps the host ceiling true across all of
2055/// them.
2056#[derive(Debug)]
2057pub struct Reconciler {
2058    host: Host,
2059    demand: Arc<dyn DemandSource>,
2060    launcher: Arc<dyn RunnerLauncher>,
2061    lock: Arc<dyn AllocationLock>,
2062    repositories: RepositoryCache,
2063    clock: Arc<dyn Clock>,
2064    jitter: Arc<dyn Jitter>,
2065    events: Arc<dyn EventSink>,
2066    schedule: PollSchedule,
2067    clean_backoff: CleanBackoff,
2068}
2069
2070impl Reconciler {
2071    /// Build a reconciler polling at the host's configured interval.
2072    #[must_use]
2073    pub fn new(host: Host, ports: ReconcilerPorts) -> Self {
2074        let interval = host.refresh_interval;
2075        let repositories = RepositoryCache::new(
2076            Arc::clone(&ports.directory),
2077            Arc::clone(&ports.clock),
2078            interval,
2079        );
2080        Self {
2081            host,
2082            demand: ports.demand,
2083            launcher: ports.launcher,
2084            lock: ports.lock,
2085            repositories,
2086            clock: ports.clock,
2087            jitter: ports.jitter,
2088            events: ports.events,
2089            schedule: PollSchedule::new(interval),
2090            clean_backoff: CleanBackoff::default(),
2091        }
2092    }
2093
2094    #[must_use]
2095    pub const fn host(&self) -> &Host {
2096        &self.host
2097    }
2098
2099    #[must_use]
2100    pub const fn schedule(&self) -> &PollSchedule {
2101        &self.schedule
2102    }
2103
2104    /// The repository-list cache, so `f1` can report what it has spent.
2105    #[must_use]
2106    pub const fn repositories(&self) -> &RepositoryCache {
2107        &self.repositories
2108    }
2109
2110    /// One reconciliation pass over `policies`.
2111    ///
2112    /// The order of operations is `03-control-flows.md` flow 2, and the two
2113    /// steps most worth naming are the ones that are silent when they are wrong:
2114    ///
2115    /// * **Monitor-only policies are removed before the demand poll**, not
2116    ///   after. D19 says such a policy "is skipped entirely by reconciliation",
2117    ///   and a poll issued on its behalf would spend requests from the shared
2118    ///   ceiling for a policy that can never act on the answer. This is asserted
2119    ///   on [`ScalePolicy::owns_runners`] rather than deduced from
2120    ///   `max_capacity` being absent.
2121    /// * **The attempt set is re-read under the lock, once per runtime.** See
2122    ///   [`RunnerLauncher`] for why it comes from there and nowhere else.
2123    pub async fn reconcile(&mut self, policies: &[ScalePolicy]) -> ReconcileReport {
2124        let mut report = ReconcileReport::default();
2125        // Everything this pass has created, carried across policies so that the
2126        // host-wide total cannot be computed from a set that is missing it. See
2127        // `RunnerLauncher::launch`.
2128        let mut launched: Vec<RunnerAttempt> = Vec::new();
2129
2130        // --- Flow 2.1-2.2: who is even asking, and what did GitHub say -------
2131        let mut pollable: Vec<&ScalePolicy> = Vec::new();
2132        let mut supervision_failed = BTreeSet::new();
2133        for policy in policies {
2134            if !policy.owns_runners() {
2135                report.monitor_only.push(policy.id);
2136                self.events
2137                    .emit(LifecycleEvent::MonitorOnlySkipped { policy: policy.id });
2138                continue;
2139            }
2140            if !policy.is_owned_by(self.host.id) {
2141                // Ownership rule 2 and precedence rule 4. The allocator reports
2142                // both by name below; polling on their behalf would spend
2143                // requests for an answer that cannot be acted on.
2144                continue;
2145            }
2146            match self.launcher.supervise(policy).await {
2147                Ok(intents) => {
2148                    report.replacement_intents = report
2149                        .replacement_intents
2150                        .saturating_add(u16::try_from(intents.len()).unwrap_or(u16::MAX));
2151                }
2152                Err(failure) => {
2153                    supervision_failed.insert(policy.id);
2154                    self.report_unreadable_attempts(&mut report, &failure);
2155                    continue;
2156                }
2157            }
2158            if !policy.may_start_runners() {
2159                continue;
2160            }
2161            pollable.push(policy);
2162        }
2163
2164        let readings = self.poll_targets(&pollable, &mut report).await;
2165        report.targets_read = u16::try_from(
2166            readings
2167                .values()
2168                .filter(|outcome| matches!(outcome, PollOutcome::Ready(_)))
2169                .count(),
2170        )
2171        .unwrap_or(u16::MAX);
2172        let mut routed = BTreeMap::new();
2173        for (target, outcome) in &readings {
2174            let PollOutcome::Ready(reading) = outcome else {
2175                continue;
2176            };
2177            let profiles: Vec<&ScalePolicy> = pollable
2178                .iter()
2179                .copied()
2180                .filter(|policy| &policy.target == target)
2181                .collect();
2182            let (tallies, refusals) = route_demand(policies, &profiles, reading);
2183            if let Some(first) = profiles.first() {
2184                for (reason, count) in refusals {
2185                    self.events.emit(LifecycleEvent::RoutingRefused {
2186                        policy: first.id,
2187                        reason,
2188                        count,
2189                    });
2190                }
2191            }
2192            routed.insert(target.clone(), tallies);
2193        }
2194
2195        // --- Flow 2.8: terminal attempts, whatever else this pass does -------
2196        //
2197        // Run before the allocation phase so that a report's `cleaned` count
2198        // describes the same instant its allocations do. It does not change the
2199        // arithmetic: a terminal attempt already stopped counting against
2200        // capacity when it became terminal, which is `b1`'s
2201        // `counts_against_capacity`. It touches no live process, so it is also
2202        // safe during an outage — flow 3.3 requires that running runners be
2203        // retained, and nothing here can reach one.
2204        self.clean_terminal_attempts(&mut report).await;
2205
2206        // --- Flow 2.3-2.6: the allocation -----------------------------------
2207        //
2208        // The predicates are re-tested here rather than the reading being looked
2209        // up by target, and that is not redundancy. **Targets are shared.** A
2210        // monitor-only policy watching `acme/app` alongside an autoscale policy
2211        // on the *same* repository finds a reading in the map that the other
2212        // policy paid for, and a lookup-driven loop then serves it: it emits a
2213        // demand observation on its behalf and clamps a number it has no
2214        // business seeing.
2215        //
2216        // Nothing downstream goes wrong when that happens — `may_start_runners`
2217        // is false for a monitor-only policy, so `HostAllocator` refuses it and
2218        // `to_start` is zero. It simply is not *skipped*, and D19's word is
2219        // "entirely".
2220        for policy in policies {
2221            if !policy.owns_runners() {
2222                // Already recorded and reported above, before any demand request
2223                // was issued. It owns no routing labels, takes no part in
2224                // demand, and can never be the reason a runner starts. Asserted
2225                // on the mode rather than deduced from `max_capacity` being
2226                // absent, which is what the specification requires.
2227                continue;
2228            }
2229            if !policy.is_owned_by(self.host.id) || !policy.may_start_runners() {
2230                // Ownership rule 2 and precedence rule 4. Allocated for with no
2231                // demand, so the refusal is reported by name rather than by
2232                // absence.
2233                match self.allocate_only(policy, 0, &launched).await {
2234                    Ok(allocation) => {
2235                        self.emit_allocation(&allocation);
2236                        report.allocations.push(allocation);
2237                    }
2238                    Err(failure) => self.report_unreadable_attempts(&mut report, &failure),
2239                }
2240                continue;
2241            }
2242            if supervision_failed.contains(&policy.id) {
2243                continue;
2244            }
2245            let Some(reading) = readings.get(&policy.target) else {
2246                // Unreachable: every policy reaching here was in `pollable`, and
2247                // `poll_targets` inserts an outcome for each of their targets.
2248                debug_assert!(false, "a pollable policy's target has no reading");
2249                continue;
2250            };
2251            match reading {
2252                PollOutcome::Failed(state) => {
2253                    report.unreadable.push(policy.id);
2254                    self.events.emit(LifecycleEvent::TargetUnreadable {
2255                        policy: policy.id,
2256                        reason: unreadable_reason(state),
2257                    });
2258                }
2259                PollOutcome::Ready(demand) => {
2260                    let tally = routed
2261                        .get(&policy.target)
2262                        .and_then(|tallies| tallies.get(&policy.id))
2263                        .expect("every pollable profile has a routed tally");
2264                    let count = tally.demand();
2265                    self.events.emit(LifecycleEvent::DemandObserved {
2266                        policy: policy.id,
2267                        demand: count,
2268                        not_matched: tally.not_matched,
2269                        unresolvable: u32::try_from(tally.unresolvable.len()).unwrap_or(u32::MAX),
2270                        complete: demand.is_complete(),
2271                    });
2272                    self.start_runners(policy, count, &mut report, &mut launched)
2273                        .await;
2274                }
2275            }
2276        }
2277
2278        // --- Flow 2.1 / 3.3: when to come back ------------------------------
2279        let failure = readings
2280            .values()
2281            .filter_map(PollOutcome::failure)
2282            .max_by_key(|state| severity(state))
2283            .cloned();
2284        let now = self.clock.now();
2285        report.next_poll = self
2286            .schedule
2287            .next_poll(failure.as_ref(), now, self.jitter.as_ref());
2288        report.failure = failure;
2289        // Flow 3.3's fourth obligation: the offline state carries the 24-hour
2290        // bound, and it can only say whether that bound has passed if it is
2291        // given the real elapsed time rather than an estimate from the interval.
2292        if let PollPace::Offline { consecutive } = report.next_poll.pace {
2293            let state = OfflineState::new(consecutive, report.next_poll.delay);
2294            report.offline = Some(match self.schedule.offline_for(now) {
2295                Some(elapsed) => state.since(elapsed),
2296                None => state,
2297            });
2298        }
2299        self.events.emit(LifecycleEvent::PollScheduled {
2300            retry_in_ms: u64::try_from(report.next_poll.delay.as_millis()).unwrap_or(u64::MAX),
2301            pace: report.next_poll.pace,
2302        });
2303
2304        report
2305    }
2306
2307    /// Poll each distinct target once, however many policies share it.
2308    ///
2309    /// Two policies on one repository are one demand request, not two. That is
2310    /// not a micro-optimisation: the budget model in
2311    /// `04-subsystem-contracts.md` prices a *target*, and a loop that spent per
2312    /// policy would quietly exceed the projection `f2` admitted the
2313    /// configuration against.
2314    async fn poll_targets(
2315        &self,
2316        pollable: &[&ScalePolicy],
2317        report: &mut ReconcileReport,
2318    ) -> BTreeMap<ScaleTarget, PollOutcome> {
2319        let targets: BTreeSet<ScaleTarget> = pollable.iter().map(|p| p.target.clone()).collect();
2320
2321        let mut readings = BTreeMap::new();
2322        for target in targets {
2323            let outcome = match self.repositories.scope_for(&target).await {
2324                Ok(scope) => {
2325                    report.demand_requests = report
2326                        .demand_requests
2327                        .saturating_add(demand_requests_per_poll(&scope));
2328                    self.demand.poll(&scope).await
2329                }
2330                // The repository list could not be refreshed, so the scope of
2331                // the poll is unknown. Polling a stale or empty scope would
2332                // report a demand number for a set of repositories nobody
2333                // chose, which is worse than reporting that the target could
2334                // not be read.
2335                Err(error) => PollOutcome::Failed(RefreshState::from_error(&error)),
2336            };
2337            readings.insert(target, outcome);
2338        }
2339        readings
2340    }
2341
2342    /// Compute one policy's allocation without creating anything.
2343    ///
2344    /// # Why this is safe without the lock
2345    ///
2346    /// **Not** because it cannot grant — it can, and
2347    /// [`Reconciler::start_runners`] uses it as a pre-check precisely for the
2348    /// number it returns. That was the original reason and this function
2349    /// outgrew it; the reason now is that it *decides* nothing. Nothing is
2350    /// created here, the headroom it read is re-read under the lock before any
2351    /// runtime exists, and the under-lock allocation may only lower what this
2352    /// one proposed. So there is no read-decide-create sequence here to make
2353    /// atomic, and the worst this can be is optimistic — which the lock then
2354    /// corrects.
2355    ///
2356    /// # Errors
2357    /// Whatever [`RunnerLauncher::attempts`] reported. A failure is never the
2358    /// same answer as an empty set.
2359    async fn allocate_only(
2360        &self,
2361        policy: &ScalePolicy,
2362        demand: u32,
2363        launched: &[RunnerAttempt],
2364    ) -> Result<Allocation, LaunchFailure> {
2365        let attempts = self.host_attempts(launched).await?;
2366        let mut allocator = HostAllocator::from_attempts(&self.host, &attempts);
2367        Ok(allocator.allocate(policy, demand))
2368    }
2369
2370    /// Flow 2.4-2.6: start runners for one policy, one lock hold per runtime.
2371    ///
2372    /// # Two stopping conditions, and both are needed
2373    ///
2374    /// The loop re-reads the attempt set under every hold, so the obvious stop
2375    /// is "the allocator granted nothing". That condition **alone does not
2376    /// terminate**, and the failure is not hypothetical — it was measured.
2377    /// Handing the allocator a set that does not include the runners this loop
2378    /// just started (an empty one, a stale one, or a launcher whose journal
2379    /// write has not landed yet) makes every grant look like the first, and the
2380    /// pass starts runners until something outside it intervenes. With the set
2381    /// dropped entirely, the three-consecutive-polls test below does not report
2382    /// three attempts; it *never returns*.
2383    ///
2384    /// So the grant decided on the first hold is also a **budget**. A later hold
2385    /// may lower it — the host may have filled up meanwhile — and can never
2386    /// raise it, which bounds the pass at the number this policy was actually
2387    /// allocated. That is `c2`'s reasoning for `MAX_PAGES` one layer down: the
2388    /// reconciliation loop is the one place in this product that must not be
2389    /// able to wedge, so the bound is structural rather than a consequence of
2390    /// every input being well behaved.
2391    async fn start_runners(
2392        &self,
2393        policy: &ScalePolicy,
2394        demand: u32,
2395        report: &mut ReconcileReport,
2396        launched: &mut Vec<RunnerAttempt>,
2397    ) {
2398        // A lock-free pre-check, for one reason only: the host-wide lock should
2399        // not be taken by a policy that is going to be granted nothing. On an
2400        // idle host with P policies that was P lock acquisitions per poll --
2401        // free under `InProcessAllocationLock`, a `spawn_blocking` and a
2402        // filesystem lock apiece under `FileAllocationLock`.
2403        //
2404        // It is safe because it can only be optimistic. Anything it grants is
2405        // re-decided under the lock below and may be lowered there; the only
2406        // thing it can get wrong in the other direction is refusing a grant that
2407        // headroom freed a moment later would have allowed, which the next poll
2408        // picks up.
2409        let intent = match self.allocate_only(policy, demand, launched).await {
2410            Ok(intent) => intent,
2411            Err(failure) => {
2412                self.report_unreadable_attempts(report, &failure);
2413                return;
2414            }
2415        };
2416
2417        // The allocation that is *reported* is the one taken under the lock when
2418        // a lock was taken, because that is the one that decided anything. The
2419        // pre-check stands in only when no hold was ever obtained.
2420        let mut decided: Option<Allocation> = None;
2421        let mut budget = intent.to_start;
2422
2423        while budget > 0 {
2424            let waiting_since = std::time::Instant::now();
2425            let guard = match self.lock.acquire().await {
2426                Ok(guard) => guard,
2427                Err(_) => {
2428                    // Grants, not policies: this is what the policy was owed and
2429                    // did not get.
2430                    report.deferred = report.deferred.saturating_add(budget);
2431                    self.events.emit(LifecycleEvent::AllocationDeferred {
2432                        policy: policy.id,
2433                        count: budget,
2434                    });
2435                    break;
2436                }
2437            };
2438            let lock_wait = waiting_since.elapsed();
2439
2440            // The read and the decision are both inside the hold, and so is the
2441            // creation below. Two concurrent passes therefore serialise on the
2442            // whole sequence rather than on the decision alone -- reading the
2443            // headroom outside the lock is the shape in which two policies both
2444            // find room for the last slot.
2445            let attempts = match self.host_attempts(launched).await {
2446                Ok(attempts) => attempts,
2447                Err(failure) => {
2448                    drop(guard);
2449                    self.report_unreadable_attempts(report, &failure);
2450                    break;
2451                }
2452            };
2453            let mut allocator = HostAllocator::from_attempts(&self.host, &attempts);
2454            let allocation = allocator.allocate(policy, demand);
2455
2456            if decided.is_none() {
2457                // The under-lock decision may be smaller than the pre-check, and
2458                // never larger: `min` rather than assignment, so a later hold
2459                // cannot raise the bound either.
2460                budget = budget.min(allocation.to_start);
2461                decided = Some(allocation.clone());
2462            }
2463
2464            // Either stop is sufficient on its own in the well-behaved case;
2465            // neither is sufficient when the launcher lags. See the doc comment.
2466            if allocation.starts_nothing() || budget == 0 {
2467                drop(guard);
2468                break;
2469            }
2470
2471            let created = self
2472                .launcher
2473                .launch(LaunchRequest {
2474                    host: &self.host,
2475                    policy,
2476                    allocation_guard: &guard,
2477                    lock_wait,
2478                })
2479                .await;
2480            drop(guard);
2481
2482            match created {
2483                Ok(attempt) => {
2484                    let id = attempt.id;
2485                    // Carried across policies for the rest of this pass, so the
2486                    // host-wide total cannot be computed from a set that is
2487                    // missing it. See `RunnerLauncher::launch`.
2488                    launched.push(attempt);
2489                    report.started = report.started.saturating_add(1);
2490                    budget -= 1;
2491                    self.events.emit(LifecycleEvent::RunnerStarted {
2492                        policy: policy.id,
2493                        attempt: id,
2494                    });
2495                }
2496                Err(failure) => {
2497                    self.events.emit(LifecycleEvent::RunnerStartFailed {
2498                        policy: policy.id,
2499                        reason: failure_reason_kind(&failure.reason),
2500                    });
2501                    break;
2502                }
2503            }
2504        }
2505
2506        let allocation = decided.unwrap_or(intent);
2507        self.emit_allocation(&allocation);
2508        report.allocations.push(allocation);
2509    }
2510
2511    /// The attempt set the host holds, plus everything this pass has already
2512    /// created.
2513    ///
2514    /// The merge is by [`RunnerAttempt::id`], so a launcher that makes its
2515    /// launches visible before returning -- which
2516    /// [`RunnerLauncher::launch`] asks for -- contributes each attempt once, and
2517    /// one that lags still cannot hide a runner from the host-wide total. The
2518    /// ceiling therefore holds on the strength of this function rather than on
2519    /// the strength of an implementer honouring a comment.
2520    ///
2521    /// # Errors
2522    /// Whatever [`RunnerLauncher::attempts`] reported.
2523    async fn host_attempts(
2524        &self,
2525        launched: &[RunnerAttempt],
2526    ) -> Result<Vec<RunnerAttempt>, LaunchFailure> {
2527        let mut attempts = self.launcher.attempts().await?;
2528
2529        // Each `launch` creates one runtime, so each must answer with an
2530        // identifier no other attempt has. Two entries sharing one here are two
2531        // runtimes the host-wide total below counts once, which is the ceiling
2532        // failing silently -- so a development build stops at the first
2533        // duplicate instead. `RunnerLauncher::launch` states the requirement;
2534        // this is what makes it findable.
2535        debug_assert!(
2536            launched
2537                .iter()
2538                .map(|attempt| attempt.id)
2539                .collect::<BTreeSet<AttemptId>>()
2540                .len()
2541                == launched.len(),
2542            "`RunnerLauncher::launch` returned an AttemptId this pass had already seen; \
2543             the host ceiling is enforced against a set keyed on that identifier, so a \
2544             duplicate is two runtimes counted as one"
2545        );
2546
2547        let known: BTreeSet<AttemptId> = attempts.iter().map(|attempt| attempt.id).collect();
2548        attempts.extend(
2549            launched
2550                .iter()
2551                .filter(|attempt| !known.contains(&attempt.id))
2552                .cloned(),
2553        );
2554        Ok(attempts)
2555    }
2556
2557    /// The attempt set could not be read, so nothing may be decided from it.
2558    ///
2559    /// Counted rather than swallowed for the reason the module documentation
2560    /// gives: an unreadable set and an idle host produce the same *number* and
2561    /// demand opposite actions, so the difference has to survive into the
2562    /// report.
2563    fn report_unreadable_attempts(&self, report: &mut ReconcileReport, failure: &LaunchFailure) {
2564        report.attempts_unreadable = report.attempts_unreadable.saturating_add(1);
2565        // The variant, never a literal and never the detail. A hand-written
2566        // `"attempts_unreadable"` said only what the event's own name already
2567        // said, and threw away the one thing the field is for -- *which* failure
2568        // it was. `FailureReason::Other` carries free text that must not reach
2569        // an event, which is what `failure_reason_kind` is for and what
2570        // `a_cleanup_that_cannot_succeed_...` pins for the sibling path.
2571        self.events.emit(LifecycleEvent::AttemptsUnreadable {
2572            reason: failure_reason_kind(&failure.reason),
2573        });
2574    }
2575
2576    /// Remove the runtimes of attempts that have already concluded.
2577    ///
2578    /// `is_concluded` and not `is_terminal`: `cleaned` is terminal and already
2579    /// done, and `busy` is not terminal at all. That is what makes it impossible
2580    /// for this path to reach a runner executing a job.
2581    async fn clean_terminal_attempts(&mut self, report: &mut ReconcileReport) {
2582        let attempts = match self.launcher.attempts().await {
2583            Ok(attempts) => attempts,
2584            Err(failure) => {
2585                self.report_unreadable_attempts(report, &failure);
2586                return;
2587            }
2588        };
2589        self.clean_backoff
2590            .retain(&attempts.iter().map(|attempt| attempt.id).collect());
2591        let now = self.clock.now();
2592        for attempt in attempts {
2593            if !attempt.state().is_concluded() {
2594                continue;
2595            }
2596            let Some(outcome) = attempt.outcome() else {
2597                continue;
2598            };
2599            // Backed off after failing: not tried, so neither cleaned nor a
2600            // fresh failure to report.
2601            if !self.clean_backoff.is_due(attempt.id, now) {
2602                continue;
2603            }
2604            let kind = OutcomeKind::of(outcome);
2605            match self.launcher.clean(attempt.id).await {
2606                Ok(()) => {
2607                    self.clean_backoff.succeeded(attempt.id);
2608                    report.cleaned = report.cleaned.saturating_add(1);
2609                    if kind.is_failure() {
2610                        report.failures = report.failures.saturating_add(1);
2611                    } else if kind == OutcomeKind::IdleExit {
2612                        // The surplus case. Counted apart from a failure because
2613                        // `g2` renders it apart, and because an operator told
2614                        // that a normal surplus exit is an error goes hunting a
2615                        // fault that does not exist.
2616                        report.idle_exits = report.idle_exits.saturating_add(1);
2617                    }
2618                    self.events.emit(LifecycleEvent::AttemptCleaned {
2619                        policy: attempt.policy_id,
2620                        attempt: attempt.id,
2621                        outcome: kind,
2622                    });
2623                }
2624                // A runtime directory that cannot be removed is retried on every
2625                // poll. Silently, before this arm existed: no event, no counter,
2626                // no report field, so a cleanup that can never succeed was an
2627                // invisible permanent loop. It wedges no capacity -- a terminal
2628                // attempt already stopped counting -- but this module's
2629                // organising principle is the things that go wrong silently, and
2630                // `clean` returns a `Result` precisely so the caller can say
2631                // something. It is retried with per-attempt backoff
2632                // ([`CLEAN_RETRY_INITIAL`]) so the saying is not a flood.
2633                Err(failure) => {
2634                    self.clean_backoff.failed(attempt.id, now);
2635                    report.clean_failures = report.clean_failures.saturating_add(1);
2636                    self.events.emit(LifecycleEvent::AttemptCleanFailed {
2637                        policy: attempt.policy_id,
2638                        attempt: attempt.id,
2639                        reason: failure_reason_kind(&failure.reason),
2640                    });
2641                }
2642            }
2643        }
2644    }
2645
2646    /// Reclaim what can be reclaimed for one policy, and nothing else.
2647    ///
2648    /// **A busy attempt is never removed.** `04-subsystem-contracts.md`:
2649    /// *"`busy` cannot transition to cleanup due to a scale-down request"*.
2650    /// Capacity comes back when an attempt reaches a terminal state and at no
2651    /// other time, so a scale-down against a host full of busy runners removes
2652    /// nothing, changes nothing, and says so.
2653    pub async fn scale_down(&self, policy: &ScalePolicy) -> ScaleDownReport {
2654        let mut report = ScaleDownReport::default();
2655        let attempts = match self.launcher.attempts().await {
2656            Ok(attempts) => attempts,
2657            Err(failure) => {
2658                // The same rule as everywhere else, and this was the one place
2659                // it was still broken: an unreadable set is not an empty one,
2660                // and a bare `default()` here reported all zeros -- byte for
2661                // byte an idle host with nothing to reclaim.
2662                self.events.emit(LifecycleEvent::AttemptsUnreadable {
2663                    reason: failure_reason_kind(&failure.reason),
2664                });
2665                report.attempts_unreadable = true;
2666                return report;
2667            }
2668        };
2669        for attempt in attempts {
2670            if attempt.policy_id != policy.id {
2671                continue;
2672            }
2673            match attempt.state() {
2674                AttemptState::Busy => {
2675                    report.refused_busy = report.refused_busy.saturating_add(1);
2676                    self.events.emit(LifecycleEvent::ScaleDownRefused {
2677                        policy: policy.id,
2678                        attempt: attempt.id,
2679                    });
2680                }
2681                state if state.is_concluded() => {
2682                    let kind = attempt
2683                        .outcome()
2684                        .map_or(OutcomeKind::Failed, OutcomeKind::of);
2685                    match self.launcher.clean(attempt.id).await {
2686                        Ok(()) => {
2687                            report.removed = report.removed.saturating_add(1);
2688                            self.events.emit(LifecycleEvent::AttemptCleaned {
2689                                policy: policy.id,
2690                                attempt: attempt.id,
2691                                outcome: kind,
2692                            });
2693                        }
2694                        Err(failure) => {
2695                            report.clean_failures = report.clean_failures.saturating_add(1);
2696                            self.events.emit(LifecycleEvent::AttemptCleanFailed {
2697                                policy: policy.id,
2698                                attempt: attempt.id,
2699                                reason: failure_reason_kind(&failure.reason),
2700                            });
2701                        }
2702                    }
2703                }
2704                AttemptState::Cleaned => {}
2705                // `allocated`, `jit_received`, `starting`, `idle`: live, holding
2706                // a slot, and not this function's to end.
2707                _ => report.retained = report.retained.saturating_add(1),
2708            }
2709        }
2710        report
2711    }
2712
2713    fn emit_allocation(&self, allocation: &Allocation) {
2714        self.events.emit(LifecycleEvent::Allocated {
2715            policy: allocation.policy_id,
2716            demand: allocation.demand,
2717            desired: allocation.desired,
2718            active_owned: allocation.active_owned,
2719            headroom: allocation.headroom_before,
2720            to_start: allocation.to_start,
2721            limiting: allocation.limiting_factor,
2722        });
2723    }
2724}
2725
2726/// One policy's demand, from the reading its target answered with.
2727///
2728/// A repository target tallies its own repository's queued jobs; an organization
2729/// target tallies every repository its scope covered, because one policy watching
2730/// an organization serves any repository in it.
2731///
2732/// # Why this takes a whole policy rather than a target and a label set
2733///
2734/// Because both halves have to come from the same policy, and a signature that
2735/// took them separately made it possible for them not to. The predecessor took a
2736/// `&ScaleTarget` alone and could not filter at all; the obvious repair was to
2737/// add a `&RoutingLabels` beside it, and at three call sites — two of them in
2738/// tests — nothing would have caught passing one policy's target with another
2739/// policy's labels. It compiles, it runs, and it silently serves the wrong
2740/// repository's queue.
2741///
2742/// # A monitor-only policy has no labels, and cannot reach here
2743///
2744/// [`Reconciler::reconcile`] filters on [`ScalePolicy::owns_runners`] before any
2745/// demand request is issued (D19), so the `None` arm is unreachable rather than
2746/// merely unlikely. It returns an empty tally instead of unwrapping, because a
2747/// panic in the reconciliation loop would take the daemon down over a policy
2748/// that was only ever going to start nothing.
2749fn demand_for(policy: &ScalePolicy, reading: &QueuedDemand) -> DemandTally {
2750    let Some(labels) = policy.routing_labels() else {
2751        debug_assert!(
2752            false,
2753            "a monitor-only policy is skipped before the demand poll (D19)"
2754        );
2755        return DemandTally::default();
2756    };
2757
2758    match &policy.target {
2759        ScaleTarget::Repository(repository) => labels.tally(reading.jobs_for(repository)),
2760        ScaleTarget::Organization(_) => labels.tally(reading.jobs()),
2761    }
2762}
2763
2764/// Tally one shared target reading using GitHub's required-label subset rule.
2765/// Refuse ambiguous ownership, including inactive local profiles: disabling an
2766/// isolated profile must not redirect its broad-label demand to a native sibling.
2767fn route_demand(
2768    all_policies: &[ScalePolicy],
2769    profiles: &[&ScalePolicy],
2770    reading: &QueuedDemand,
2771) -> (
2772    BTreeMap<PolicyId, DemandTally>,
2773    BTreeMap<RoutingRefusal, u32>,
2774) {
2775    let mut tallies: BTreeMap<PolicyId, DemandTally> = profiles
2776        .iter()
2777        .map(|policy| (policy.id, demand_for(policy, reading)))
2778        .collect();
2779    let mut refusals: BTreeMap<RoutingRefusal, u32> = BTreeMap::new();
2780    let Some(first) = profiles.first() else {
2781        return (tallies, refusals);
2782    };
2783    let owners: Vec<_> = all_policies
2784        .iter()
2785        .filter(|policy| policy.target == first.target && policy.host_id == first.host_id)
2786        .filter(|policy| policy.owns_runners())
2787        .collect();
2788    let jobs: Vec<&runner_manager_domain::policy::RunsOn> = match &first.target {
2789        ScaleTarget::Repository(repository) => reading.jobs_for(repository).iter().collect(),
2790        ScaleTarget::Organization(_) => reading.jobs().collect(),
2791    };
2792    for job in jobs {
2793        let Ok(_) = job.required_labels() else {
2794            // `demand_for` already preserves this as unresolvable for each profile.
2795            continue;
2796        };
2797        let matched: Vec<PolicyId> = owners
2798            .iter()
2799            .filter(|policy| {
2800                policy
2801                    .routing_labels()
2802                    .is_some_and(|labels| labels.matches(job).is_match())
2803            })
2804            .map(|policy| policy.id)
2805            .collect();
2806        if matched.len() > 1 {
2807            *refusals
2808                .entry(RoutingRefusal::OverlappingProfiles)
2809                .or_default() += 1;
2810            for id in matched {
2811                if let Some(tally) = tallies.get_mut(&id) {
2812                    tally.matched -= 1;
2813                    tally.not_matched += 1;
2814                }
2815            }
2816        }
2817    }
2818    (tallies, refusals)
2819}
2820
2821/// How urgently one failure should slow the loop down.
2822///
2823/// Ordering matters only for picking the worst of several targets: an outage
2824/// outranks a rate limit because backing off a socket that is not answering is
2825/// the safer error, and both outrank a per-target rejection that says nothing
2826/// about the credential as a whole.
2827const fn severity(state: &RefreshState) -> u8 {
2828    match state {
2829        RefreshState::Offline => 5,
2830        RefreshState::RateLimited(_) => 4,
2831        RefreshState::LockedOut { .. } => 3,
2832        RefreshState::Unauthorized => 2,
2833        RefreshState::Forbidden { .. } | RefreshState::Failed { .. } => 1,
2834        RefreshState::Cancelled | RefreshState::Ready(_) => 0,
2835    }
2836}
2837
2838/// Why one target could not be read, as a fixed, credential-free name.
2839///
2840/// Deliberately not a [`PollPace`]: a pace describes the *schedule*, which is a
2841/// property of the whole pass, and stamping one onto a single target would have
2842/// meant inventing a `consecutive` count for a target that has none. What an
2843/// event needs here is the reason, and `c3`'s [`RefreshState`] already names it.
2844///
2845/// `RefreshState::Failed` carries GitHub's own message and
2846/// `RefreshState::Forbidden` may carry one too. Neither reaches the event: this
2847/// returns the variant, for the reason [`failure_reason_kind`] states.
2848const fn unreadable_reason(state: &RefreshState) -> &'static str {
2849    match state {
2850        RefreshState::Ready(_) => "ready",
2851        RefreshState::Offline => "offline",
2852        RefreshState::RateLimited(_) => "rate_limited",
2853        RefreshState::LockedOut { .. } => "locked_out",
2854        RefreshState::Unauthorized => "unauthorized",
2855        RefreshState::Forbidden { .. } => "forbidden",
2856        RefreshState::Failed { .. } => "failed",
2857        RefreshState::Cancelled => "cancelled",
2858    }
2859}
2860
2861#[cfg(test)]
2862mod tests {
2863    use super::*;
2864
2865    /// The reading that said `healthy` for 28 hours while nothing worked.
2866    ///
2867    /// A daemon every one of whose targets answered `401` kept writing a fresh
2868    /// `last GitHub contact`, because an unauthorized target is `unreadable`
2869    /// rather than a `failure`. Both that record and the `service status` built
2870    /// on it were used as evidence during the investigation, and both were
2871    /// wrong; see `docs/spikes/token-expiry-and-renewal.md`.
2872    #[test]
2873    fn a_pass_that_reached_no_target_does_not_claim_it_reached_github() {
2874        let mut report = ReconcileReport::default();
2875        assert!(
2876            !report.reached_github(),
2877            "a pass that polled nothing -- every policy draining, owned elsewhere, or \
2878             monitor-only -- reached nobody. This is the case the old guard let through, and \
2879             the only one it ever let through."
2880        );
2881
2882        report.unreadable.push(PolicyId::from_u128(1));
2883        assert!(
2884            !report.reached_github(),
2885            "every target this pass tried was unreadable, so there is no contact to record"
2886        );
2887
2888        report.targets_read = 1;
2889        assert!(
2890            report.reached_github(),
2891            "one target answering is contact, whatever else failed alongside it"
2892        );
2893
2894        // `allocations` deliberately does not count: a policy this host does
2895        // not own is allocated for with no demand and without polling anything,
2896        // so a pass where every poll failed can still carry allocations.
2897        let mut unowned = ReconcileReport::default();
2898        unowned.unreadable.push(PolicyId::from_u128(2));
2899        unowned.allocations.push(Allocation {
2900            policy_id: PolicyId::from_u128(2),
2901            demand: 0,
2902            desired: 0,
2903            active_owned: 0,
2904            headroom_before: 0,
2905            to_start: 0,
2906            limiting_factor: LimitingFactor::Demand,
2907        });
2908        assert!(
2909            !unowned.reached_github(),
2910            "an allocation is not evidence that GitHub answered"
2911        );
2912    }
2913
2914    /// The claim the old guard rested on, checked rather than assumed.
2915    ///
2916    /// `report.failure.is_none()` was believed to be compatible with an
2917    /// all-unauthorized pass. It is not: `unreadable` is pushed only from the
2918    /// `Failed` arm and `failure` is the maximum over every `Failed` reading,
2919    /// so guarding on `failure.is_none() && reached_github()` would have been
2920    /// `failure.is_none()` with extra words. This pins the severity that makes
2921    /// it so, because a future `severity(Unauthorized) == 0` would quietly
2922    /// restore the belief.
2923    #[test]
2924    fn an_unauthorized_target_is_a_failure_and_not_merely_unreadable() {
2925        assert!(
2926            severity(&RefreshState::Unauthorized) > 0,
2927            "an unauthorized reading must survive `max_by_key(severity)` into `report.failure`, \
2928             or a pass where every target was refused would report no failure at all"
2929        );
2930    }
2931
2932    use std::sync::atomic::AtomicUsize;
2933
2934    use std::num::NonZeroU16;
2935
2936    use runner_manager_domain::attempt::PersistedAttempt;
2937    use runner_manager_domain::model::{CachePolicy, HostId};
2938    use runner_manager_domain::policy::PolicyMode;
2939    use runner_manager_domain::workspace::WorkspaceKind;
2940    use runner_manager_github::rest::RateLimited;
2941    use runner_manager_testkit::clock::FakeClock;
2942    use runner_manager_testkit::fixtures;
2943    use runner_manager_testkit::github::FakeGithub;
2944
2945    // =======================================================================
2946    // Fakes
2947    // =======================================================================
2948
2949    fn host_with(capacity: u16) -> Host {
2950        fixtures::host().capacity(capacity).build()
2951    }
2952
2953    fn repo(raw: &str) -> OwnerRepo {
2954        OwnerRepo::parse(raw).expect("a valid OWNER/REPO")
2955    }
2956
2957    /// An `active`, enabled autoscale policy on the fixture host.
2958    use runner_manager_domain::policy::RunsOn;
2959
2960    fn policy(id: u128, target: &str, max: u16) -> ScalePolicy {
2961        fixtures::policy()
2962            .id(PolicyId::from_u128(id))
2963            .repository(target)
2964            .autoscale("home", max)
2965            .active()
2966            .build()
2967    }
2968
2969    /// The host label every policy in these tests carries.
2970    ///
2971    /// `policy` above builds through `fixtures::policy().autoscale("home", …)`,
2972    /// which derives `rm-home-win-x64`. A job fixture that did not carry it
2973    /// would be filtered out as another host's work, so the two are tied
2974    /// together here rather than repeated as a literal at each call site.
2975    const HOST_LABEL: &str = "rm-home-win-x64";
2976
2977    /// `n` queued jobs this host's policies match.
2978    ///
2979    /// The ordinary demand fixture. Since the reversal of the run-counting
2980    /// decision the unit `e1` clamps is a job, so a test wanting demand `n` asks
2981    /// for `n` jobs rather than for `n` runs.
2982    fn jobs(n: usize) -> Vec<RunsOn> {
2983        fixtures::queued_jobs(&[HOST_LABEL], n)
2984    }
2985
2986    /// `e3`, faked: an attempt table and a launch counter, no process anywhere.
2987    #[derive(Debug, Default)]
2988    struct FakeLauncher {
2989        attempts: Mutex<Vec<RunnerAttempt>>,
2990        next_id: AtomicU64,
2991        launches: AtomicUsize,
2992        cleaned: Mutex<Vec<AttemptId>>,
2993        /// Yields this many times between reading the attempt set and recording
2994        /// a new one, so an unserialised allocator has a window to be wrong in.
2995        yields_before_recording: usize,
2996        /// Reports success without the attempt ever becoming visible, which is
2997        /// the shape a slow journal write has. Every grant then looks like the
2998        /// first.
2999        forgetful: bool,
3000        fail_next: Mutex<Option<FailureReason>>,
3001        /// Reports that the attempt set cannot be read at all, which is the one
3002        /// answer a caller must never confuse with an idle host.
3003        attempts_fail: Mutex<bool>,
3004        /// Refuses every cleanup, so the silent-retry path has something to be
3005        /// loud about.
3006        clean_fails: bool,
3007        replacements: Mutex<Vec<ReplacementIntent>>,
3008    }
3009
3010    impl FakeLauncher {
3011        fn new() -> Self {
3012            Self::default()
3013        }
3014
3015        fn with_yields(mut self, yields: usize) -> Self {
3016            self.yields_before_recording = yields;
3017            self
3018        }
3019
3020        fn forgetful() -> Self {
3021            Self {
3022                forgetful: true,
3023                ..Self::default()
3024            }
3025        }
3026
3027        fn seeded(self, attempts: Vec<RunnerAttempt>) -> Self {
3028            *self.attempts.lock().unwrap() = attempts;
3029            self
3030        }
3031
3032        fn launches(&self) -> usize {
3033            self.launches.load(Ordering::SeqCst)
3034        }
3035
3036        fn snapshot(&self) -> Vec<RunnerAttempt> {
3037            self.attempts.lock().unwrap().clone()
3038        }
3039
3040        fn live_count(&self) -> usize {
3041            self.snapshot()
3042                .iter()
3043                .filter(|a| a.counts_against_capacity())
3044                .count()
3045        }
3046
3047        fn fail_next(&self, reason: FailureReason) {
3048            *self.fail_next.lock().unwrap() = Some(reason);
3049        }
3050
3051        fn fail_attempts(&self, failing: bool) {
3052            *self.attempts_fail.lock().unwrap() = failing;
3053        }
3054
3055        fn refusing_cleanup(attempts: Vec<RunnerAttempt>) -> Self {
3056            Self {
3057                clean_fails: true,
3058                ..Self::default()
3059            }
3060            .seeded(attempts)
3061        }
3062
3063        fn cleaned(&self) -> Vec<AttemptId> {
3064            self.cleaned.lock().unwrap().clone()
3065        }
3066
3067        fn replacing(self, intent: ReplacementIntent) -> Self {
3068            self.replacements.lock().unwrap().push(intent);
3069            self
3070        }
3071    }
3072
3073    #[async_trait::async_trait]
3074    impl RunnerLauncher for FakeLauncher {
3075        async fn supervise(
3076            &self,
3077            policy: &ScalePolicy,
3078        ) -> Result<Vec<ReplacementIntent>, LaunchFailure> {
3079            let mut replacements = self.replacements.lock().unwrap();
3080            let selected: Vec<_> = replacements
3081                .extract_if(.., |intent| intent.policy == policy.id)
3082                .collect();
3083            if !selected.is_empty() {
3084                let retired: BTreeSet<_> = selected
3085                    .iter()
3086                    .map(|intent| intent.previous_attempt)
3087                    .collect();
3088                self.attempts
3089                    .lock()
3090                    .unwrap()
3091                    .retain(|attempt| !retired.contains(&attempt.id));
3092            }
3093            Ok(selected)
3094        }
3095
3096        async fn attempts(&self) -> Result<Vec<RunnerAttempt>, LaunchFailure> {
3097            if *self.attempts_fail.lock().unwrap() {
3098                return Err(LaunchFailure::new(FailureReason::Other(
3099                    "the journal could not be read".into(),
3100                )));
3101            }
3102            Ok(self.snapshot())
3103        }
3104
3105        async fn launch(&self, request: LaunchRequest<'_>) -> Result<RunnerAttempt, LaunchFailure> {
3106            if let Some(reason) = self.fail_next.lock().unwrap().take() {
3107                return Err(LaunchFailure::new(reason));
3108            }
3109            // The window an unserialised caller would lose the race in.
3110            for _ in 0..self.yields_before_recording {
3111                tokio::task::yield_now().await;
3112            }
3113            let id =
3114                AttemptId::from_u128(u128::from(self.next_id.fetch_add(1, Ordering::SeqCst) + 1));
3115            let created = RunnerAttempt::allocate(
3116                id,
3117                request.policy.id,
3118                "runtime/p/a",
3119                request.host.created_at,
3120            );
3121            self.launches.fetch_add(1, Ordering::SeqCst);
3122            if !self.forgetful {
3123                self.attempts.lock().unwrap().push(created.clone());
3124            }
3125            Ok(created)
3126        }
3127
3128        async fn clean(&self, attempt: AttemptId) -> Result<(), LaunchFailure> {
3129            if self.clean_fails {
3130                return Err(LaunchFailure::new(FailureReason::Other(
3131                    "the runtime directory is locked".into(),
3132                )));
3133            }
3134            self.cleaned.lock().unwrap().push(attempt);
3135            let mut attempts = self.attempts.lock().unwrap();
3136            attempts.retain(|a| a.id != attempt);
3137            Ok(())
3138        }
3139    }
3140
3141    /// A demand source a test programs directly, with no gateway underneath.
3142    #[derive(Debug, Default)]
3143    struct FakeDemand {
3144        outcome: Mutex<Option<PollOutcome>>,
3145        /// Answers programmed for one target, which beat the blanket one.
3146        per_target: Mutex<BTreeMap<ScaleTarget, PollOutcome>>,
3147        scopes: Mutex<Vec<ActivityScope>>,
3148    }
3149
3150    impl FakeDemand {
3151        fn ready(count: u32, repository: &OwnerRepo) -> Self {
3152            let fake = Self::default();
3153            fake.set(PollOutcome::Ready(QueuedDemand::of(
3154                repository.clone(),
3155                jobs(count as usize),
3156            )));
3157            fake
3158        }
3159
3160        fn failing(state: RefreshState) -> Self {
3161            let fake = Self::default();
3162            fake.set(PollOutcome::Failed(state));
3163            fake
3164        }
3165
3166        fn set(&self, outcome: PollOutcome) {
3167            *self.outcome.lock().unwrap() = Some(outcome);
3168        }
3169
3170        /// Program one target's answer, overriding the blanket one.
3171        fn set_for(&self, target: &ScaleTarget, outcome: PollOutcome) {
3172            self.per_target
3173                .lock()
3174                .unwrap()
3175                .insert(target.clone(), outcome);
3176        }
3177
3178        fn polls(&self) -> Vec<ActivityScope> {
3179            self.scopes.lock().unwrap().clone()
3180        }
3181    }
3182
3183    #[async_trait::async_trait]
3184    impl DemandSource for FakeDemand {
3185        async fn poll(&self, scope: &ActivityScope) -> PollOutcome {
3186            self.scopes.lock().unwrap().push(scope.clone());
3187            if let Some(outcome) = self.per_target.lock().unwrap().get(scope.target()) {
3188                return outcome.clone();
3189            }
3190            self.outcome
3191                .lock()
3192                .unwrap()
3193                .clone()
3194                .unwrap_or(PollOutcome::Ready(QueuedDemand::default()))
3195        }
3196    }
3197
3198    #[derive(Debug, Default)]
3199    struct FakeDirectory {
3200        repositories: Vec<OwnerRepo>,
3201        calls: AtomicUsize,
3202    }
3203
3204    impl FakeDirectory {
3205        fn of(repositories: Vec<OwnerRepo>) -> Self {
3206            Self {
3207                repositories,
3208                calls: AtomicUsize::new(0),
3209            }
3210        }
3211
3212        fn calls(&self) -> usize {
3213            self.calls.load(Ordering::SeqCst)
3214        }
3215    }
3216
3217    #[async_trait::async_trait]
3218    impl RepositoryDirectory for FakeDirectory {
3219        async fn repositories(&self, _org: &Org) -> Result<Vec<OwnerRepo>, InventoryError> {
3220            self.calls.fetch_add(1, Ordering::SeqCst);
3221            Ok(self.repositories.clone())
3222        }
3223    }
3224
3225    /// A lock that grants everything and counts how many holders it had at once.
3226    ///
3227    /// The counter is the assertion: "under simulated lock contention" is only
3228    /// meaningful if something measures that the contention was actually
3229    /// serialised.
3230    #[derive(Debug)]
3231    struct CountingLock {
3232        inner: InProcessAllocationLock,
3233        concurrent: Arc<AtomicUsize>,
3234        peak: Arc<AtomicUsize>,
3235        acquisitions: Arc<AtomicUsize>,
3236    }
3237
3238    impl CountingLock {
3239        fn new() -> Self {
3240            Self {
3241                inner: InProcessAllocationLock::new(),
3242                concurrent: Arc::new(AtomicUsize::new(0)),
3243                peak: Arc::new(AtomicUsize::new(0)),
3244                acquisitions: Arc::new(AtomicUsize::new(0)),
3245            }
3246        }
3247
3248        fn peak(&self) -> usize {
3249            self.peak.load(Ordering::SeqCst)
3250        }
3251
3252        fn acquisitions(&self) -> usize {
3253            self.acquisitions.load(Ordering::SeqCst)
3254        }
3255    }
3256
3257    #[derive(Debug)]
3258    struct CountingGuard {
3259        _inner: AllocationGuard,
3260        concurrent: Arc<AtomicUsize>,
3261    }
3262
3263    impl Drop for CountingGuard {
3264        fn drop(&mut self) {
3265            self.concurrent.fetch_sub(1, Ordering::SeqCst);
3266        }
3267    }
3268
3269    #[async_trait::async_trait]
3270    impl AllocationLock for CountingLock {
3271        async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
3272            let inner = self.inner.acquire().await?;
3273            self.acquisitions.fetch_add(1, Ordering::SeqCst);
3274            let now = self.concurrent.fetch_add(1, Ordering::SeqCst) + 1;
3275            self.peak.fetch_max(now, Ordering::SeqCst);
3276            Ok(AllocationGuard::new(CountingGuard {
3277                _inner: inner,
3278                concurrent: Arc::clone(&self.concurrent),
3279            }))
3280        }
3281    }
3282
3283    /// The lock that is not one: what the host looks like with the serialisation
3284    /// removed. Used only by the control half of the contention test.
3285    #[derive(Debug, Default)]
3286    struct NoLock;
3287
3288    #[async_trait::async_trait]
3289    impl AllocationLock for NoLock {
3290        async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
3291            Ok(AllocationGuard::new(()))
3292        }
3293    }
3294
3295    #[tokio::test]
3296    async fn a_wsl_drain_request_fences_the_exact_pre_launch_boundary() {
3297        use runner_manager_platform::paths::AppPaths;
3298        use runner_manager_platform::wsl::fence::{DrainRequest, GuestRecoveryConfig};
3299
3300        let local = tempfile::tempdir().unwrap();
3301        let shared = tempfile::tempdir().unwrap();
3302        let paths = Arc::new(AppPaths::rooted_at(local.path()));
3303        paths.create_all().unwrap();
3304        GuestRecoveryConfig::new(shared.path().to_path_buf())
3305            .write(&paths)
3306            .unwrap();
3307        DrainRequest::new(4, chrono::Utc::now())
3308            .write(shared.path())
3309            .unwrap();
3310        let lock = WslRecoveryAllocationLock::new(paths, Arc::new(NoLock));
3311
3312        assert!(lock.acquire().await.is_err());
3313    }
3314
3315    #[tokio::test]
3316    async fn a_guest_launch_claim_is_released_with_its_allocation_guard() {
3317        use runner_manager_platform::paths::AppPaths;
3318        use runner_manager_platform::wsl::fence::{FENCE_DIRECTORY, GuestRecoveryConfig};
3319
3320        let local = tempfile::tempdir().unwrap();
3321        let shared = tempfile::tempdir().unwrap();
3322        let paths = Arc::new(AppPaths::rooted_at(local.path()));
3323        paths.create_all().unwrap();
3324        GuestRecoveryConfig::new(shared.path().to_path_buf())
3325            .write(&paths)
3326            .unwrap();
3327        let lock = WslRecoveryAllocationLock::new(paths, Arc::new(NoLock));
3328        let guard = lock.acquire().await.unwrap();
3329        assert!(shared.path().join(FENCE_DIRECTORY).exists());
3330        drop(guard);
3331        assert!(!shared.path().join(FENCE_DIRECTORY).exists());
3332    }
3333
3334    /// A lock nobody can take.
3335    #[derive(Debug, Default)]
3336    struct HeldLock;
3337
3338    #[async_trait::async_trait]
3339    impl AllocationLock for HeldLock {
3340        async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
3341            Err(AllocationLockBusy)
3342        }
3343    }
3344
3345    /// Everything one test needs, wired together.
3346    struct Harness {
3347        launcher: Arc<FakeLauncher>,
3348        demand: Arc<FakeDemand>,
3349        events: Arc<EventLog>,
3350        clock: Arc<FakeClock>,
3351        reconciler: Reconciler,
3352    }
3353
3354    impl Harness {
3355        fn build(
3356            host: Host,
3357            launcher: Arc<FakeLauncher>,
3358            demand: Arc<FakeDemand>,
3359            lock: Arc<dyn AllocationLock>,
3360        ) -> Self {
3361            let events = Arc::new(EventLog::new());
3362            let clock = Arc::new(FakeClock::default());
3363            let reconciler = Reconciler::new(
3364                host,
3365                ReconcilerPorts {
3366                    demand: Arc::clone(&demand) as Arc<dyn DemandSource>,
3367                    launcher: Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
3368                    lock,
3369                    directory: Arc::new(FakeDirectory::default()),
3370                    clock: Arc::clone(&clock) as Arc<dyn Clock>,
3371                    jitter: Arc::new(NoJitter) as Arc<dyn Jitter>,
3372                    events: Arc::clone(&events) as Arc<dyn EventSink>,
3373                },
3374            );
3375            Self {
3376                launcher,
3377                demand,
3378                events,
3379                clock,
3380                reconciler,
3381            }
3382        }
3383
3384        fn simple(capacity: u16, demand_count: u32, target: &str) -> Self {
3385            let launcher = Arc::new(FakeLauncher::new());
3386            let demand = Arc::new(FakeDemand::ready(demand_count, &repo(target)));
3387            Self::build(
3388                host_with(capacity),
3389                launcher,
3390                demand,
3391                Arc::new(InProcessAllocationLock::new()),
3392            )
3393        }
3394    }
3395
3396    fn attempt_in(state: AttemptState, id: u128, policy: u128) -> RunnerAttempt {
3397        let outcome = state.is_terminal().then(|| match state {
3398            AttemptState::Failed => {
3399                AttemptOutcome::failed(FailureReason::ProcessExitedUnexpectedly)
3400            }
3401            AttemptState::Orphaned => AttemptOutcome::Orphaned,
3402            _ => AttemptOutcome::CompletedJob,
3403        });
3404        RunnerAttempt::from_persisted(PersistedAttempt {
3405            id: AttemptId::from_u128(id),
3406            policy_id: PolicyId::from_u128(policy),
3407            github_runner_id: None,
3408            state,
3409            outcome,
3410            process_id: None,
3411            execution: runner_manager_domain::execution::AttemptExecution::Native {
3412                process_id: None,
3413            },
3414            runtime_path: "runtime/p/a".into(),
3415            workspace_kind: WorkspaceKind::Ephemeral,
3416            workspace_slot: None,
3417            created_at: fixtures::created_at(),
3418            terminal_at: state.is_terminal().then(fixtures::created_at),
3419            last_state_change_at: fixtures::created_at(),
3420        })
3421        .expect("a state/outcome pair the domain accepts")
3422    }
3423
3424    /// A concluded attempt carrying a specific outcome.
3425    fn concluded(id: u128, policy: u128, outcome: AttemptOutcome) -> RunnerAttempt {
3426        RunnerAttempt::from_persisted(PersistedAttempt {
3427            id: AttemptId::from_u128(id),
3428            policy_id: PolicyId::from_u128(policy),
3429            github_runner_id: None,
3430            state: outcome.terminal_state(),
3431            outcome: Some(outcome),
3432            process_id: None,
3433            execution: runner_manager_domain::execution::AttemptExecution::Native {
3434                process_id: None,
3435            },
3436            runtime_path: "runtime/p/a".into(),
3437            workspace_kind: WorkspaceKind::Ephemeral,
3438            workspace_slot: None,
3439            created_at: fixtures::created_at(),
3440            terminal_at: Some(fixtures::created_at()),
3441            last_state_change_at: fixtures::created_at(),
3442        })
3443        .expect("a state/outcome pair the domain accepts")
3444    }
3445
3446    // =======================================================================
3447    // The in-flight term: the single most likely way this task goes wrong
3448    // =======================================================================
3449
3450    /// `e1`'s Definition of Done, verbatim: *"A job that remains `queued` across
3451    /// three consecutive polls while its attempt is `starting` yields exactly
3452    /// one attempt — the test fails if the in-flight term is dropped from the
3453    /// formula."*
3454    ///
3455    /// `b1` tests the arithmetic underneath this
3456    /// (`capacity::tests::the_same_queued_job_on_two_polls_yields_one_attempt_
3457    /// not_two`). What *this* test covers is the only way `e1` can drop the
3458    /// term without touching `b1` at all: handing the allocator an attempt set
3459    /// that is not the one the host holds.
3460    ///
3461    /// # This was measured, not assumed, and the first measurement was worse
3462    /// # than the failure it was looking for
3463    ///
3464    /// Replacing `self.launcher.attempts().await` in
3465    /// [`Reconciler::start_runners`] with `Vec::new()` compiles and runs. Before
3466    /// that function carried a budget, this test did not go red — it **never
3467    /// returned**: every grant looked like the first, so the pass started
3468    /// runners forever inside poll 1. That is the runaway-runner failure exactly
3469    /// as an operator would meet it, and it is why the budget exists.
3470    ///
3471    /// With the budget in place the same injection fails cleanly and says what
3472    /// happened: `poll 2 … left: 2, right: 1`. Both measurements were run
3473    /// before this assertion was written.
3474    #[tokio::test]
3475    async fn three_polls_of_one_still_queued_run_yield_exactly_one_attempt() {
3476        let mut harness = Harness::simple(4, 1, "acme/app");
3477        let policy = policy(1, "acme/app", 4);
3478
3479        for poll in 1..=3 {
3480            let report = harness
3481                .reconciler
3482                .reconcile(std::slice::from_ref(&policy))
3483                .await;
3484            assert_eq!(
3485                harness.launcher.launches(),
3486                1,
3487                "poll {poll} started another runner for a job already being served; the \
3488                 `- active_owned_runners` term reached `HostAllocator` as a set this host \
3489                 does not hold"
3490            );
3491            assert_eq!(report.allocations.len(), 1);
3492            let allocation = &report.allocations[0];
3493            assert_eq!(allocation.demand, 1, "poll {poll}: still queued at GitHub");
3494            if poll == 1 {
3495                assert_eq!(allocation.to_start, 1);
3496                assert_eq!(report.started, 1);
3497            } else {
3498                assert_eq!(allocation.active_owned, 1, "poll {poll}");
3499                assert_eq!(allocation.to_start, 0, "poll {poll}");
3500                assert_eq!(report.started, 0, "poll {poll}");
3501            }
3502        }
3503        assert_eq!(harness.launcher.live_count(), 1);
3504    }
3505
3506    /// The other half of the measurement above: the loop must terminate even
3507    /// when the attempt set never catches up with it.
3508    ///
3509    /// Dropping the in-flight term made
3510    /// `three_polls_of_one_still_queued_run_yield_exactly_one_attempt` hang
3511    /// rather than fail — the loop had one stopping condition and it was the one
3512    /// the bug removed. A launcher whose journal write has not landed presents
3513    /// exactly the same shape without any bug at all, so the budget in
3514    /// [`Reconciler::start_runners`] bounds the pass structurally. This is what
3515    /// asserts the bound is really there.
3516    #[tokio::test]
3517    async fn a_launcher_whose_attempts_never_appear_cannot_wedge_the_pass() {
3518        let launcher = Arc::new(FakeLauncher::forgetful());
3519        let mut harness = Harness::build(
3520            host_with(64),
3521            Arc::clone(&launcher),
3522            Arc::new(FakeDemand::ready(3, &repo("acme/app"))),
3523            Arc::new(InProcessAllocationLock::new()),
3524        );
3525
3526        let report = harness
3527            .reconciler
3528            .reconcile(&[policy(1, "acme/app", 8)])
3529            .await;
3530
3531        assert_eq!(
3532            report.started, 3,
3533            "the pass is bounded by the grant it was given, not by the attempt set catching \
3534             up with it"
3535        );
3536        assert_eq!(launcher.launches(), 3);
3537        assert!(
3538            launcher.snapshot().is_empty(),
3539            "the launcher never recorded anything, which is the whole point of the fixture"
3540        );
3541    }
3542
3543    /// The host-wide ceiling must hold across policies even when the launcher
3544    /// lags, and the per-policy budget alone does not reach that case.
3545    ///
3546    /// Review found this, with this file's own `forgetful` fixture and one more
3547    /// policy: the budget bounds *each policy's* loop to its own first grant,
3548    /// but policy B's first grant is computed from a set that does not yet
3549    /// contain policy A's launches, so B's bound is itself too large. Two
3550    /// policies on a host of three started **six** runners --
3551    /// `host_capacity=3, started=6, launches=6` -- with the lock held correctly
3552    /// throughout. Serialisation was never the problem; the arithmetic under it
3553    /// was reading a stale set.
3554    #[tokio::test]
3555    async fn two_policies_cannot_exceed_host_capacity_even_when_the_launcher_lags() {
3556        let launcher = Arc::new(FakeLauncher::forgetful());
3557        let demand = Arc::new(FakeDemand::default());
3558        demand.set_for(
3559            &ScaleTarget::repository("acme/left").unwrap(),
3560            PollOutcome::Ready(QueuedDemand::of(repo("acme/left"), jobs(3))),
3561        );
3562        demand.set_for(
3563            &ScaleTarget::repository("acme/right").unwrap(),
3564            PollOutcome::Ready(QueuedDemand::of(repo("acme/right"), jobs(3))),
3565        );
3566        let mut harness = Harness::build(
3567            host_with(3),
3568            Arc::clone(&launcher),
3569            demand,
3570            Arc::new(InProcessAllocationLock::new()),
3571        );
3572
3573        let report = harness
3574            .reconciler
3575            .reconcile(&[policy(1, "acme/left", 3), policy(2, "acme/right", 3)])
3576            .await;
3577
3578        assert_eq!(
3579            report.started, 3,
3580            "host_capacity is 3 and two policies each allowed 3 started {} runners \
3581             between them; the second policy's grant was computed from a set that did \
3582             not yet contain the first policy's launches",
3583            report.started
3584        );
3585        assert_eq!(launcher.launches(), 3);
3586    }
3587
3588    /// Finding 1: an attempt set that cannot be read is not an empty one.
3589    ///
3590    /// `attempts()` used to be infallible, which left `e3` — reading a journal
3591    /// off a disk — a choice between panicking and answering `vec![]`. The
3592    /// second is silent and catastrophic: an empty set is indistinguishable from
3593    /// an idle host, so a transient read failure reads as "nothing is running"
3594    /// and the pass allocates the whole machine for jobs already being served.
3595    ///
3596    /// The contrast is the assertion. Identical host, identical demand,
3597    /// identical policy; the only difference is whether the launcher can answer.
3598    #[tokio::test]
3599    async fn an_unreadable_attempt_set_starts_nothing_and_is_not_read_as_an_idle_host() {
3600        let launcher = Arc::new(FakeLauncher::new());
3601        let mut harness = Harness::build(
3602            host_with(8),
3603            Arc::clone(&launcher),
3604            Arc::new(FakeDemand::ready(4, &repo("acme/app"))),
3605            Arc::new(InProcessAllocationLock::new()),
3606        );
3607        let policy = policy(1, "acme/app", 8);
3608
3609        launcher.fail_attempts(true);
3610        let unreadable = harness
3611            .reconciler
3612            .reconcile(std::slice::from_ref(&policy))
3613            .await;
3614
3615        assert_eq!(
3616            unreadable.started, 0,
3617            "nothing may be decided from a set that was not read"
3618        );
3619        assert_eq!(launcher.launches(), 0);
3620        assert!(unreadable.attempts_unreadable > 0, "and the pass says so");
3621        assert!(
3622            unreadable.allocations.is_empty(),
3623            "no allocation is reported either: there was no set to compute one from, and \
3624             an allocation of zero would claim a decision nobody made"
3625        );
3626        assert!(harness.events.count_of("attempts_unreadable") > 0);
3627
3628        // The same everything, with a launcher that can answer.
3629        launcher.fail_attempts(false);
3630        let readable = harness
3631            .reconciler
3632            .reconcile(std::slice::from_ref(&policy))
3633            .await;
3634        assert_eq!(
3635            readable.started, 4,
3636            "the difference between the two passes is only whether the set could be read"
3637        );
3638        assert_eq!(readable.attempts_unreadable, 0);
3639    }
3640
3641    /// Finding 3: a cleanup that can never succeed was an invisible permanent
3642    /// loop.
3643    ///
3644    /// `if …clean(…).await.is_ok()` had no `else`, so a runtime directory that
3645    /// could not be removed was retried on every poll with no event, no counter
3646    /// and no report field. It wedges no capacity — a terminal attempt already
3647    /// stopped counting — but `clean` returns a `Result` precisely so the caller
3648    /// can say something, and this module's organising principle is the things
3649    /// that go wrong silently.
3650    #[tokio::test]
3651    async fn a_cleanup_that_cannot_succeed_is_reported_rather_than_retried_in_silence() {
3652        let launcher = Arc::new(FakeLauncher::refusing_cleanup(vec![concluded(
3653            1,
3654            1,
3655            AttemptOutcome::ExitedIdleWithoutWork,
3656        )]));
3657        let mut harness = Harness::build(
3658            host_with(4),
3659            Arc::clone(&launcher),
3660            Arc::new(FakeDemand::ready(0, &repo("acme/app"))),
3661            Arc::new(InProcessAllocationLock::new()),
3662        );
3663
3664        let report = harness
3665            .reconciler
3666            .reconcile(&[policy(1, "acme/app", 4)])
3667            .await;
3668
3669        assert_eq!(report.cleaned, 0);
3670        assert_eq!(report.clean_failures, 1);
3671        assert_eq!(harness.events.count_of("attempt_clean_failed"), 1);
3672        assert_eq!(
3673            harness.events.count_of("attempt_cleaned"),
3674            0,
3675            "and it is not reported as cleaned"
3676        );
3677        assert_eq!(
3678            launcher.snapshot().len(),
3679            1,
3680            "the attempt is still there, so the retry is real -- what changed is that it \
3681             is no longer silent"
3682        );
3683
3684        // The reason is the variant, never the detail: the fixture's failure
3685        // carries free text and none of it reaches the event.
3686        let reasons: Vec<&'static str> = harness
3687            .events
3688            .events()
3689            .into_iter()
3690            .filter_map(|event| match event {
3691                LifecycleEvent::AttemptCleanFailed { reason, .. } => Some(reason),
3692                _ => None,
3693            })
3694            .collect();
3695        assert_eq!(reasons, vec!["other"]);
3696    }
3697
3698    /// A cleanup that keeps failing is retried with per-attempt exponential
3699    /// backoff rather than on every pass, and a success forgets the backoff.
3700    #[tokio::test]
3701    async fn a_failing_cleanup_backs_off_exponentially_per_attempt() {
3702        let launcher = Arc::new(FakeLauncher::refusing_cleanup(vec![concluded(
3703            1,
3704            1,
3705            AttemptOutcome::ExitedIdleWithoutWork,
3706        )]));
3707        let mut harness = Harness::build(
3708            host_with(4),
3709            Arc::clone(&launcher),
3710            Arc::new(FakeDemand::ready(0, &repo("acme/app"))),
3711            Arc::new(InProcessAllocationLock::new()),
3712        );
3713        let policies = [policy(1, "acme/app", 4)];
3714        let start = harness.clock.now();
3715        let at = |offset: Duration| start + chrono::Duration::from_std(offset).unwrap();
3716        let mut failures_at = async |offset: Duration| {
3717            harness.clock.set(at(offset));
3718            harness.reconciler.reconcile(&policies).await.clean_failures
3719        };
3720
3721        assert_eq!(failures_at(Duration::ZERO).await, 1);
3722        // Inside the first delay: not tried, so nothing new to report.
3723        assert_eq!(
3724            failures_at(CLEAN_RETRY_INITIAL - Duration::from_secs(1)).await,
3725            0,
3726            "a failed cleanup was retried on the very next pass"
3727        );
3728        assert_eq!(failures_at(CLEAN_RETRY_INITIAL).await, 1);
3729        // The second delay is twice the first.
3730        let second = CLEAN_RETRY_INITIAL + CLEAN_RETRY_INITIAL * 2;
3731        assert_eq!(
3732            failures_at(second - Duration::from_secs(1)).await,
3733            0,
3734            "the delay did not double after a second failure"
3735        );
3736        assert_eq!(failures_at(second).await, 1);
3737        assert_eq!(harness.events.count_of("attempt_clean_failed"), 3);
3738
3739        // The delay never exceeds the ceiling, however many failures accrue.
3740        let mut backoff = CleanBackoff::default();
3741        let id = AttemptId::from_u128(9);
3742        for _ in 0..40 {
3743            backoff.failed(id, start);
3744        }
3745        assert!(!backoff.is_due(id, at(CLEAN_RETRY_MAXIMUM - Duration::from_secs(1))));
3746        assert!(backoff.is_due(id, at(CLEAN_RETRY_MAXIMUM)));
3747        backoff.succeeded(id);
3748        assert!(
3749            backoff.is_due(id, start),
3750            "a cleaned attempt kept its backoff"
3751        );
3752    }
3753
3754    /// N2: an unreadable attempt set makes a scale-down inconclusive, not empty.
3755    ///
3756    /// Making `attempts()` fallible closed this everywhere the allocation path
3757    /// touches, and left it open in the one place that returns a different type:
3758    /// `scale_down` answered `ScaleDownReport::default()`, which is all zeros
3759    /// and byte-for-byte identical to an idle host with nothing to reclaim. The
3760    /// two mean opposite things — "there was nothing to remove" against "we
3761    /// cannot see what there was".
3762    ///
3763    /// Measured as the sibling test measures it: identical host, identical
3764    /// attempts, identical policy, and the only difference is whether the
3765    /// launcher can answer.
3766    #[tokio::test]
3767    async fn an_unreadable_attempt_set_makes_scale_down_inconclusive_rather_than_empty() {
3768        let launcher = Arc::new(FakeLauncher::new().seeded(vec![
3769            attempt_in(AttemptState::Busy, 1, 1),
3770            concluded(2, 1, AttemptOutcome::CompletedJob),
3771        ]));
3772        let harness = Harness::build(
3773            host_with(4),
3774            Arc::clone(&launcher),
3775            Arc::new(FakeDemand::ready(0, &repo("acme/app"))),
3776            Arc::new(InProcessAllocationLock::new()),
3777        );
3778        let policy = policy(1, "acme/app", 4);
3779
3780        launcher.fail_attempts(true);
3781        let blind = harness.reconciler.scale_down(&policy).await;
3782
3783        assert!(!blind.is_conclusive(), "the machine was never read");
3784        assert_ne!(
3785            blind,
3786            ScaleDownReport::default(),
3787            "a scale-down that could not see the host must not be equal to one that saw \
3788             an idle host; that equality is the whole finding"
3789        );
3790        assert_eq!(blind.removed, 0);
3791        assert_eq!(
3792            blind.refused_busy, 0,
3793            "and this zero means `unknown`, not `none`"
3794        );
3795        assert_eq!(harness.events.count_of("attempts_unreadable"), 1);
3796
3797        // The same everything, with a launcher that can answer.
3798        launcher.fail_attempts(false);
3799        let seeing = harness.reconciler.scale_down(&policy).await;
3800
3801        assert!(seeing.is_conclusive());
3802        assert_eq!(seeing.removed, 1, "the concluded attempt was reclaimed");
3803        assert_eq!(seeing.refused_busy, 1, "and the busy one was left alone");
3804        assert_ne!(
3805            seeing, blind,
3806            "the difference between the two is only whether the set could be read"
3807        );
3808    }
3809
3810    /// Finding 7: the lower arm of the clamp, driven through the reconciler.
3811    ///
3812    /// `demand_below_min_capacity_starts_nothing_in_v1` runs `demand = 0`
3813    /// against `min_capacity = 0`, which is *at* the floor and never raises
3814    /// `desired` — the assertion held for a reason unrelated to the boundary it
3815    /// named. D7 fixes `min` at 0 for v1, but `AutoscaleConfig::new` accepts
3816    /// `min > 0` today, so the path is representable and was undriven.
3817    #[tokio::test]
3818    async fn demand_below_min_capacity_is_raised_to_min_capacity() {
3819        let mut warm = ScalePolicy::new(
3820            PolicyId::from_u128(1),
3821            ScaleTarget::repository("acme/app").unwrap(),
3822            1,
3823            fixtures::HOST_ID,
3824            PolicyMode::autoscale(
3825                fixtures::routing_labels("home"),
3826                2,
3827                NonZeroU16::new(5).expect("non-zero"),
3828            )
3829            .expect("min <= max"),
3830            CachePolicy::default(),
3831        );
3832        warm.activate().expect("pending -> active");
3833
3834        let mut harness = Harness::simple(8, 0, "acme/app");
3835        let report = harness.reconciler.reconcile(&[warm]).await;
3836
3837        assert_eq!(
3838            report.allocations[0].demand, 0,
3839            "GitHub reported no queued runs"
3840        );
3841        assert_eq!(
3842            report.allocations[0].desired, 2,
3843            "min_capacity raised the target above demand"
3844        );
3845        assert_eq!(
3846            report.allocations[0].limiting_factor,
3847            LimitingFactor::MinCapacity
3848        );
3849        assert_eq!(report.started, 2, "and two runners were actually started");
3850        assert_eq!(harness.launcher.live_count(), 2);
3851    }
3852
3853    // =======================================================================
3854    // Capacity, at the boundaries
3855    // =======================================================================
3856
3857    #[tokio::test]
3858    async fn demand_above_max_capacity_is_clamped_to_max_capacity() {
3859        let mut harness = Harness::simple(100, 10, "acme/app");
3860        let report = harness
3861            .reconciler
3862            .reconcile(&[policy(1, "acme/app", 3)])
3863            .await;
3864
3865        assert_eq!(report.allocations[0].demand, 10);
3866        assert_eq!(
3867            report.allocations[0].desired, 3,
3868            "max_capacity beats demand"
3869        );
3870        assert_eq!(report.started, 3);
3871        assert_eq!(
3872            report.allocations[0].limiting_factor,
3873            LimitingFactor::MaxCapacity
3874        );
3875    }
3876
3877    #[tokio::test]
3878    async fn demand_below_min_capacity_starts_nothing_in_v1() {
3879        // D7 fixes `min_capacity` at 0, so "below the floor" is "no demand", and
3880        // the product requirement it satisfies is "no idle runners when unused".
3881        let mut harness = Harness::simple(8, 0, "acme/app");
3882        let report = harness
3883            .reconciler
3884            .reconcile(&[policy(1, "acme/app", 4)])
3885            .await;
3886
3887        assert_eq!(report.allocations[0].desired, 0);
3888        assert_eq!(report.started, 0);
3889        assert!(report.starts_nothing());
3890    }
3891
3892    #[tokio::test]
3893    async fn lifecycle_replacement_intent_is_consumed_by_the_ordinary_allocator() {
3894        let policy = policy(1, "octo/repo", 1);
3895        let previous = attempt_in(AttemptState::Starting, 41, 1);
3896        let intent = ReplacementIntent {
3897            policy: policy.id,
3898            previous_attempt: previous.id,
3899            operation: "exit_before_acceptance_replacement",
3900        };
3901        let launcher = Arc::new(FakeLauncher::new().seeded(vec![previous]).replacing(intent));
3902        let demand = Arc::new(FakeDemand::ready(1, &repo("octo/repo")));
3903        let mut harness = Harness::build(
3904            host_with(1),
3905            Arc::clone(&launcher),
3906            demand,
3907            Arc::new(InProcessAllocationLock::new()),
3908        );
3909
3910        let report = harness.reconciler.reconcile(&[policy]).await;
3911
3912        assert_eq!(report.replacement_intents, 1);
3913        assert_eq!(report.started, 1);
3914        assert_eq!(launcher.launches(), 1);
3915        assert_eq!(launcher.live_count(), 1);
3916    }
3917
3918    #[tokio::test]
3919    async fn zero_host_headroom_starts_nothing_at_maximum_demand() {
3920        let launcher = Arc::new(FakeLauncher::new().seeded(vec![
3921            attempt_in(AttemptState::Busy, 1, 1),
3922            attempt_in(AttemptState::Busy, 2, 1),
3923        ]));
3924        let demand = Arc::new(FakeDemand::ready(u32::from(u16::MAX), &repo("acme/app")));
3925        let mut harness = Harness::build(
3926            host_with(2),
3927            launcher,
3928            demand,
3929            Arc::new(InProcessAllocationLock::new()),
3930        );
3931
3932        let report = harness
3933            .reconciler
3934            .reconcile(&[policy(1, "acme/app", 2)])
3935            .await;
3936        assert_eq!(report.started, 0);
3937        assert_eq!(report.allocations[0].headroom_before, 0);
3938        assert_eq!(harness.launcher.launches(), 0);
3939    }
3940
3941    #[tokio::test]
3942    async fn headroom_smaller_than_the_per_policy_allowance_wins() {
3943        // Four slots held by *another* policy on a host of six: this policy is
3944        // allowed five and gets two.
3945        let launcher = Arc::new(FakeLauncher::new().seeded(vec![
3946            attempt_in(AttemptState::Busy, 1, 99),
3947            attempt_in(AttemptState::Busy, 2, 99),
3948            attempt_in(AttemptState::Idle, 3, 99),
3949            attempt_in(AttemptState::Starting, 4, 99),
3950        ]));
3951        let demand = Arc::new(FakeDemand::ready(5, &repo("acme/app")));
3952        let mut harness = Harness::build(
3953            host_with(6),
3954            launcher,
3955            demand,
3956            Arc::new(InProcessAllocationLock::new()),
3957        );
3958
3959        let report = harness
3960            .reconciler
3961            .reconcile(&[policy(1, "acme/app", 5)])
3962            .await;
3963        assert_eq!(
3964            report.allocations[0].desired, 5,
3965            "its own ceiling allows five"
3966        );
3967        assert_eq!(report.started, 2, "the host has two slots free");
3968        assert_eq!(
3969            report.allocations[0].limiting_factor,
3970            LimitingFactor::HostCapacity
3971        );
3972        assert_eq!(harness.launcher.live_count(), 6);
3973    }
3974
3975    #[tokio::test]
3976    async fn the_idle_host_assertion_holds() {
3977        // "No demand means zero runner processes and zero attempts out of
3978        // terminal state."
3979        let mut harness = Harness::simple(8, 0, "acme/app");
3980        let report = harness
3981            .reconciler
3982            .reconcile(&[policy(1, "acme/app", 4), policy(2, "acme/app", 4)])
3983            .await;
3984
3985        assert_eq!(report.started, 0);
3986        assert_eq!(harness.launcher.launches(), 0);
3987        assert!(harness.launcher.snapshot().is_empty());
3988        assert_eq!(
3989            harness
3990                .launcher
3991                .snapshot()
3992                .iter()
3993                .filter(|a| !a.is_terminal())
3994                .count(),
3995            0
3996        );
3997    }
3998
3999    // =======================================================================
4000    // D9 under concurrency: the other silent failure
4001    // =======================================================================
4002
4003    /// `e1`'s Definition of Done: *"Two policies on one host with
4004    /// `host_capacity` smaller than the sum of their `max_capacity` values never
4005    /// exceed `host_capacity` under concurrent reconciliation — asserted under
4006    /// simulated lock contention, with no duplicate runners."*
4007    ///
4008    /// The contention is simulated by [`FakeLauncher::with_yields`], which puts
4009    /// executor yield points *between* the launcher reading the attempt set and
4010    /// recording the new one. Without serialisation both tasks read a headroom
4011    /// of three and both spend it.
4012    ///
4013    /// # Watched failing before it was made to pass
4014    ///
4015    /// Granting from this lock without taking the inner mutex — leaving every
4016    /// counter and every yield point exactly as they are — fails this assertion
4017    /// with `left: 4, right: 3`: four runners on a host of three, from two
4018    /// policies each individually inside their own `max_capacity`. The control
4019    /// test below keeps that measurement standing permanently by running the
4020    /// same body against [`NoLock`].
4021    #[tokio::test(flavor = "current_thread")]
4022    async fn two_policies_reconciling_concurrently_never_exceed_host_capacity() {
4023        let lock = Arc::new(CountingLock::new());
4024        let (launches, live) =
4025            two_policies_concurrently(Arc::clone(&lock) as Arc<dyn AllocationLock>).await;
4026
4027        assert_eq!(
4028            launches, 3,
4029            "the sum across policies must never exceed host_capacity, and each policy is \
4030             individually within its own max_capacity of 3"
4031        );
4032        assert_eq!(live, 3, "and no duplicate runner survived the race");
4033        assert_eq!(
4034            lock.peak(),
4035            1,
4036            "the allocation lock had one holder at a time; without that the read of the \
4037             headroom and the creation of the runtime are not atomic"
4038        );
4039        assert!(
4040            (3..=5).contains(&lock.acquisitions()),
4041            "the lock is taken before *each* runtime, not once per pass: three runtimes \
4042             means at least three holds, and at most one further hold per policy to \
4043             discover the host filled up underneath it. It was taken {} times",
4044            lock.acquisitions()
4045        );
4046    }
4047
4048    /// The control for the test above: the same body with the lock removed.
4049    ///
4050    /// It exists so that the assertion above cannot pass vacuously. If a future
4051    /// change makes the unserialised path safe by accident — a launcher that
4052    /// records synchronously, say — this test goes red and says so, rather than
4053    /// the other one silently proving nothing.
4054    #[tokio::test(flavor = "current_thread")]
4055    async fn without_the_allocation_lock_two_policies_oversubscribe_the_host() {
4056        let (launches, _) =
4057            two_policies_concurrently(Arc::new(NoLock) as Arc<dyn AllocationLock>).await;
4058
4059        assert!(
4060            launches > 3,
4061            "with no serialisation both policies must be able to spend the same headroom; \
4062             they started {launches} runners on a host of 3. If this is ever 3, the \
4063             contention window closed and `two_policies_reconciling_concurrently_never_\
4064             exceed_host_capacity` has stopped proving anything"
4065        );
4066    }
4067
4068    /// Two policies, one host of three, each allowed three, reconciled at once.
4069    ///
4070    /// Returns `(launches, live attempts)`.
4071    async fn two_policies_concurrently(lock: Arc<dyn AllocationLock>) -> (usize, usize) {
4072        let launcher = Arc::new(FakeLauncher::new().with_yields(4));
4073        let host = host_with(3);
4074
4075        let mut left = Harness::build(
4076            host.clone(),
4077            Arc::clone(&launcher),
4078            Arc::new(FakeDemand::ready(3, &repo("acme/left"))),
4079            Arc::clone(&lock),
4080        )
4081        .reconciler;
4082        let mut right = Harness::build(
4083            host,
4084            Arc::clone(&launcher),
4085            Arc::new(FakeDemand::ready(3, &repo("acme/right"))),
4086            Arc::clone(&lock),
4087        )
4088        .reconciler;
4089
4090        let a = policy(1, "acme/left", 3);
4091        let b = policy(2, "acme/right", 3);
4092
4093        let left = tokio::spawn(async move { left.reconcile(&[a]).await });
4094        let right = tokio::spawn(async move { right.reconcile(&[b]).await });
4095        let (_, _) = (left.await.unwrap(), right.await.unwrap());
4096
4097        (launcher.launches(), launcher.live_count())
4098    }
4099
4100    // =======================================================================
4101    // D19: monitor-only
4102    // =======================================================================
4103
4104    /// `e1`'s Definition of Done: *"A `MonitorOnly` policy under maximum demand
4105    /// starts zero runners and issues no demand request."*
4106    ///
4107    /// Driven through `c4`'s real gateway fake so that "issued no demand
4108    /// request" is asserted against the thing that would have issued it, rather
4109    /// than against this module's own bookkeeping. `FakeGithub` records every
4110    /// call it is asked to make.
4111    #[tokio::test]
4112    async fn a_monitor_only_policy_under_maximum_demand_starts_nothing_and_polls_nothing() {
4113        let gateway = FakeGithub::new().with_queued_jobs(repo("acme/app"), jobs(10_000));
4114        let gateway = Arc::new(GatewayDemand::new(gateway, CancelToken::new()));
4115        let launcher = Arc::new(FakeLauncher::new());
4116        let events = Arc::new(EventLog::new());
4117
4118        let mut reconciler = Reconciler::new(
4119            host_with(10),
4120            ReconcilerPorts {
4121                demand: Arc::clone(&gateway) as Arc<dyn DemandSource>,
4122                launcher: Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
4123                lock: Arc::new(InProcessAllocationLock::new()),
4124                directory: Arc::new(FakeDirectory::default()),
4125                clock: Arc::new(FakeClock::default()),
4126                jitter: Arc::new(NoJitter),
4127                events: Arc::clone(&events) as Arc<dyn EventSink>,
4128            },
4129        );
4130
4131        let monitor = fixtures::policy()
4132            .id(PolicyId::from_u128(1))
4133            .repository("acme/app")
4134            .monitor_only()
4135            .active()
4136            .build();
4137
4138        let report = reconciler.reconcile(&[monitor]).await;
4139
4140        assert_eq!(report.started, 0);
4141        assert_eq!(launcher.launches(), 0);
4142        assert_eq!(report.monitor_only, vec![PolicyId::from_u128(1)]);
4143        assert_eq!(
4144            report.demand_requests, 0,
4145            "a monitor-only policy spends nothing from the shared hourly ceiling"
4146        );
4147        assert!(
4148            gateway.gateway().calls().is_empty(),
4149            "a monitor-only policy issued a demand request: {:?}",
4150            gateway.gateway().calls()
4151        );
4152        assert_eq!(events.count_of("monitor_only_skipped"), 1);
4153        assert_eq!(
4154            events.count_of("demand_observed"),
4155            0,
4156            "and it contributed no demand"
4157        );
4158    }
4159
4160    /// D19 says a monitor-only policy is *"skipped entirely by
4161    /// reconciliation"*, and "entirely" is the load-bearing word once two
4162    /// policies share a target.
4163    ///
4164    /// This defect was found by review rather than by the test above, which
4165    /// cannot see it: there, the monitor-only policy is the *only* policy, so
4166    /// nobody polls its target and the lookup finds nothing. Give it a
4167    /// repository an autoscale policy already polls and the lookup succeeds —
4168    /// and the monitor-only policy was then allocated for and had a demand
4169    /// observation emitted on its behalf. It still started nothing, because
4170    /// `may_start_runners` is false for it and `HostAllocator` refuses it by
4171    /// name, so no ceiling was ever at risk. It simply was not skipped.
4172    ///
4173    /// Removing the `owns_runners` guard from the allocation loop was watched
4174    /// failing this test before it was restored:
4175    /// `a monitor-only policy was allocated for: [… limiting_factor:
4176    /// MonitorOnly]`.
4177    #[tokio::test]
4178    async fn a_monitor_only_policy_sharing_a_target_is_still_skipped_entirely() {
4179        let lock = Arc::new(CountingLock::new());
4180        let launcher = Arc::new(FakeLauncher::new());
4181        let events = Arc::new(EventLog::new());
4182        let mut reconciler = Reconciler::new(
4183            host_with(4),
4184            ReconcilerPorts {
4185                demand: Arc::new(FakeDemand::ready(2, &repo("acme/app"))),
4186                launcher: Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
4187                lock: Arc::clone(&lock) as Arc<dyn AllocationLock>,
4188                directory: Arc::new(FakeDirectory::default()),
4189                clock: Arc::new(FakeClock::default()),
4190                jitter: Arc::new(NoJitter),
4191                events: Arc::clone(&events) as Arc<dyn EventSink>,
4192            },
4193        );
4194
4195        let watcher = fixtures::policy()
4196            .id(PolicyId::from_u128(2))
4197            .repository("acme/app")
4198            .monitor_only()
4199            .active()
4200            .build();
4201
4202        let report = reconciler
4203            .reconcile(&[policy(1, "acme/app", 4), watcher])
4204            .await;
4205
4206        assert_eq!(report.started, 2, "the autoscale policy is served normally");
4207        assert_eq!(report.monitor_only, vec![PolicyId::from_u128(2)]);
4208        assert_eq!(
4209            events.count_of("demand_observed"),
4210            1,
4211            "the demand observation belongs to the autoscale policy alone"
4212        );
4213        assert!(
4214            report
4215                .allocations
4216                .iter()
4217                .all(|a| a.policy_id == PolicyId::from_u128(1)),
4218            "a monitor-only policy was allocated for: {:?}",
4219            report.allocations
4220        );
4221        assert_eq!(
4222            lock.acquisitions(),
4223            2,
4224            "one hold per runtime created, and none on behalf of the monitor-only policy. \
4225             It was three before the budget was checked at the top of the loop rather than \
4226             after the re-read, which cost every policy a surplus hold to discover there \
4227             was nothing left to grant"
4228        );
4229    }
4230
4231    #[tokio::test]
4232    async fn the_monitor_only_refusal_is_asserted_on_the_mode_not_on_a_missing_ceiling() {
4233        // The specification requires this to be asserted rather than deduced
4234        // from `max_capacity` being absent. `HostAllocator` reports it by name,
4235        // and this loop reaches that arm through `owns_runners`, which is a
4236        // question about the mode.
4237        let monitor = fixtures::monitor_only_policy();
4238        assert!(!monitor.owns_runners());
4239        assert_eq!(monitor.max_capacity(), None);
4240
4241        let host = host_with(10);
4242        let attempts: Vec<RunnerAttempt> = Vec::new();
4243        let mut allocator = HostAllocator::from_attempts(&host, &attempts);
4244        let allocation = allocator.allocate(&monitor, 10_000);
4245        assert_eq!(allocation.limiting_factor, LimitingFactor::MonitorOnly);
4246        assert_eq!(allocation.to_start, 0);
4247        assert_eq!(
4248            allocator.headroom(),
4249            10,
4250            "and it consumes no headroom, so an autoscale policy on the same host is \
4251             unaffected"
4252        );
4253    }
4254
4255    // =======================================================================
4256    // The surplus runner, and busy protection
4257    // =======================================================================
4258
4259    /// `e1`'s Definition of Done: *"A surplus attempt that receives no job
4260    /// reaches a terminal state recorded as an idle exit, is cleaned, and is not
4261    /// reported as a failure."*
4262    #[tokio::test]
4263    async fn a_surplus_attempt_is_cleaned_as_an_idle_exit_and_not_as_a_failure() {
4264        let launcher = Arc::new(FakeLauncher::new().seeded(vec![
4265            concluded(1, 1, AttemptOutcome::ExitedIdleWithoutWork),
4266            concluded(
4267                2,
4268                1,
4269                AttemptOutcome::failed(FailureReason::JitRequestFailed),
4270            ),
4271            concluded(3, 1, AttemptOutcome::CompletedJob),
4272        ]));
4273        let mut harness = Harness::build(
4274            host_with(4),
4275            Arc::clone(&launcher),
4276            Arc::new(FakeDemand::ready(0, &repo("acme/app"))),
4277            Arc::new(InProcessAllocationLock::new()),
4278        );
4279
4280        let report = harness
4281            .reconciler
4282            .reconcile(&[policy(1, "acme/app", 4)])
4283            .await;
4284
4285        assert_eq!(report.cleaned, 3);
4286        assert_eq!(report.idle_exits, 1, "the surplus case, counted apart");
4287        assert_eq!(
4288            report.failures, 1,
4289            "only the failed attempt is a failure; the idle exit and the completed job are \
4290             not"
4291        );
4292        assert_eq!(launcher.cleaned().len(), 3);
4293        assert!(launcher.snapshot().is_empty());
4294
4295        let cleaned: Vec<OutcomeKind> = harness
4296            .events
4297            .events()
4298            .into_iter()
4299            .filter_map(|event| match event {
4300                LifecycleEvent::AttemptCleaned { outcome, .. } => Some(outcome),
4301                _ => None,
4302            })
4303            .collect();
4304        assert!(cleaned.contains(&OutcomeKind::IdleExit));
4305        assert!(
4306            !OutcomeKind::IdleExit.is_failure(),
4307            "an idle exit rendered as a failure sends an operator hunting a fault that does \
4308             not exist"
4309        );
4310    }
4311
4312    /// `e1`'s Definition of Done: *"A scale-down request with a busy attempt
4313    /// removes nothing and leaves the attempt `busy`."*
4314    #[tokio::test]
4315    async fn scale_down_removes_nothing_from_a_busy_attempt() {
4316        let busy = attempt_in(AttemptState::Busy, 1, 1);
4317        let launcher = Arc::new(FakeLauncher::new().seeded(vec![
4318            busy.clone(),
4319            attempt_in(AttemptState::Starting, 2, 1),
4320            concluded(3, 1, AttemptOutcome::CompletedJob),
4321        ]));
4322        let harness = Harness::build(
4323            host_with(4),
4324            Arc::clone(&launcher),
4325            Arc::new(FakeDemand::ready(0, &repo("acme/app"))),
4326            Arc::new(InProcessAllocationLock::new()),
4327        );
4328
4329        let report = harness
4330            .reconciler
4331            .scale_down(&policy(1, "acme/app", 4))
4332            .await;
4333
4334        assert_eq!(report.refused_busy, 1);
4335        assert_eq!(
4336            report.retained, 1,
4337            "the `starting` attempt is not ended either"
4338        );
4339        assert_eq!(report.removed, 1, "only the concluded attempt is reclaimed");
4340
4341        let after = launcher.snapshot();
4342        let still_busy = after
4343            .iter()
4344            .find(|a| a.id == AttemptId::from_u128(1))
4345            .expect("the busy attempt is still there");
4346        assert_eq!(
4347            still_busy.state(),
4348            AttemptState::Busy,
4349            "scale-down removed nothing from a runner that is executing a job, and left it \
4350             busy"
4351        );
4352        assert!(!launcher.cleaned().contains(&AttemptId::from_u128(1)));
4353
4354        // And the domain refuses it from the other side too, by name, so a
4355        // future caller that tried anyway would not get a generic transition
4356        // error.
4357        let mut busy = busy;
4358        assert!(matches!(
4359            busy.clean(fixtures::created_at()),
4360            Err(runner_manager_domain::attempt::AttemptError::BusyCannotBeCleaned)
4361        ));
4362        assert_eq!(harness.events.count_of("scale_down_refused"), 1);
4363    }
4364
4365    // =======================================================================
4366    // The schedule
4367    // =======================================================================
4368
4369    #[test]
4370    fn the_default_interval_is_sixty_seconds_and_the_floor_is_thirty() {
4371        assert_eq!(RefreshInterval::DEFAULT_SECS, 60);
4372        assert_eq!(RefreshInterval::MIN_SECS, 30);
4373        assert_eq!(PollSchedule::floor(), Duration::from_secs(30));
4374        assert!(
4375            RefreshInterval::from_secs(29).is_err(),
4376            "the floor is a rate-budget constraint, and a caller must not be able to write \
4377             a shorter interval at all"
4378        );
4379
4380        let mut schedule = PollSchedule::new(RefreshInterval::default());
4381        let next = schedule.next_poll(None, fixtures::created_at(), &NoJitter);
4382        assert_eq!(next.delay, Duration::from_secs(60));
4383        assert_eq!(next.pace, PollPace::Nominal);
4384
4385        let mut floored = PollSchedule::new(RefreshInterval::from_secs(30).unwrap());
4386        assert_eq!(
4387            floored
4388                .next_poll(None, fixtures::created_at(), &NoJitter)
4389                .delay,
4390            Duration::from_secs(30)
4391        );
4392    }
4393
4394    /// `e1`'s Definition of Done: *"The poll interval … increases under a
4395    /// rate-limit signal, and the increase is visible in emitted state rather
4396    /// than silent."*
4397    #[test]
4398    fn a_rate_limit_increases_the_delay_and_names_itself() {
4399        let now = fixtures::created_at();
4400        let mut schedule = PollSchedule::new(RefreshInterval::default());
4401
4402        let limited = RefreshState::RateLimited(RateLimited {
4403            kind: RateLimitKind::Secondary,
4404            retry_after: Some(Duration::from_secs(300)),
4405            remaining: None,
4406            reset_unix_secs: None,
4407        });
4408        let next = schedule.next_poll(Some(&limited), now, &NoJitter);
4409
4410        assert_eq!(next.delay, Duration::from_secs(300));
4411        assert_eq!(
4412            next.pace,
4413            PollPace::RateLimited {
4414                kind: RateLimitKind::Secondary
4415            },
4416            "the increase is reported, never hidden"
4417        );
4418        assert!(next.pace.is_throttled());
4419        assert_eq!(next.pace.as_str(), "rate_limited_secondary");
4420    }
4421
4422    /// Constraint on this task: *"Read `RefreshState::retry_delay` as an
4423    /// absolute floor, not an addend."*
4424    #[test]
4425    fn the_retry_delay_is_an_absolute_floor_and_never_an_addend() {
4426        let now = fixtures::created_at();
4427        let mut schedule = PollSchedule::new(RefreshInterval::default());
4428
4429        let limited = RefreshState::RateLimited(RateLimited {
4430            kind: RateLimitKind::Primary,
4431            retry_after: Some(Duration::from_secs(300)),
4432            remaining: Some(0),
4433            reset_unix_secs: None,
4434        });
4435
4436        // Five successive answers, each carrying the window that is *left*.
4437        // An addend would compound: 360, 660, 960 … and look like a hang.
4438        for _ in 0..5 {
4439            let next = schedule.next_poll(Some(&limited), now, &NoJitter);
4440            assert_eq!(
4441                next.delay,
4442                Duration::from_secs(300),
4443                "the delay is `max(interval, retry_delay)`; `interval + retry_delay` would \
4444                 have compounded on every successive retry"
4445            );
4446        }
4447
4448        // And when GitHub asks for less than the interval, the interval wins:
4449        // the floor is never crossed to catch up.
4450        let brief = RefreshState::RateLimited(RateLimited {
4451            kind: RateLimitKind::Secondary,
4452            retry_after: Some(Duration::from_secs(5)),
4453            remaining: None,
4454            reset_unix_secs: None,
4455        });
4456        let next = schedule.next_poll(Some(&brief), now, &NoJitter);
4457        assert_eq!(
4458            next.delay,
4459            Duration::from_secs(60),
4460            "a short `retry-after` may not drop the loop below its own interval"
4461        );
4462        assert!(next.delay >= PollSchedule::floor());
4463    }
4464
4465    #[test]
4466    fn no_branch_of_the_schedule_can_go_below_the_thirty_second_floor() {
4467        let now = fixtures::created_at();
4468        let states = [
4469            None,
4470            Some(RefreshState::Offline),
4471            Some(RefreshState::RateLimited(RateLimited {
4472                kind: RateLimitKind::Secondary,
4473                retry_after: Some(Duration::from_secs(1)),
4474                remaining: None,
4475                reset_unix_secs: None,
4476            })),
4477            Some(RefreshState::LockedOut {
4478                retry_after: Duration::from_secs(1),
4479            }),
4480            Some(RefreshState::Unauthorized),
4481            Some(RefreshState::Forbidden { message: None }),
4482            Some(RefreshState::Failed {
4483                status: Some(500),
4484                message: "server error".into(),
4485            }),
4486            Some(RefreshState::Cancelled),
4487        ];
4488
4489        for state in &states {
4490            let mut schedule = PollSchedule::new(RefreshInterval::from_secs(30).unwrap());
4491            let next = schedule.next_poll(state.as_ref(), now, &NoJitter);
4492            assert!(
4493                next.delay >= PollSchedule::floor(),
4494                "{state:?} scheduled a poll {}ms away, under the 30-second floor",
4495                next.delay.as_millis()
4496            );
4497        }
4498    }
4499
4500    #[test]
4501    fn an_offline_run_backs_off_with_jitter_and_a_recovery_resets_it() {
4502        let now = fixtures::created_at();
4503        let mut schedule = PollSchedule::new(RefreshInterval::default());
4504
4505        // Doubling, from the nominal interval.
4506        let mut previous = Duration::ZERO;
4507        for consecutive in 1..=6_u32 {
4508            let next = schedule.next_poll(Some(&RefreshState::Offline), now, &NoJitter);
4509            assert_eq!(next.pace, PollPace::Offline { consecutive });
4510            assert!(
4511                next.delay >= previous,
4512                "the back-off must not shrink while the outage continues"
4513            );
4514            assert!(next.delay >= Duration::from_secs(60));
4515            previous = next.delay;
4516        }
4517        assert!(previous <= MAX_OFFLINE_BACKOFF, "and it is capped");
4518
4519        // Jitter widens the delay rather than narrowing it, so a fleet of
4520        // agents does not retry in lockstep.
4521        let mut jittered = PollSchedule::new(RefreshInterval::default());
4522        let none = jittered.next_poll(Some(&RefreshState::Offline), now, &NoJitter);
4523        let mut jittered = PollSchedule::new(RefreshInterval::default());
4524        let full = jittered.next_poll(Some(&RefreshState::Offline), now, &FixedJitter(0.999));
4525        assert!(full.delay > none.delay);
4526        assert!(full.delay <= none.delay.mul_f64(1.0 + JITTER_RATIO));
4527
4528        // Recovery resets the run with no bookkeeping of its own.
4529        assert_eq!(schedule.consecutive_offline(), 6);
4530        let recovered = schedule.next_poll(None, now, &NoJitter);
4531        assert_eq!(recovered.pace, PollPace::Nominal);
4532        assert_eq!(recovered.delay, Duration::from_secs(60));
4533        assert_eq!(schedule.consecutive_offline(), 0);
4534    }
4535
4536    #[test]
4537    fn the_offline_state_states_the_twenty_four_hour_bound() {
4538        assert_eq!(
4539            GITHUB_CANCELS_QUEUED_JOBS_AFTER,
4540            Duration::from_secs(24 * 60 * 60)
4541        );
4542
4543        let brief = OfflineState::new(1, Duration::from_secs(120));
4544        let rendered = brief.to_string();
4545        assert!(rendered.contains("24 hours"), "{rendered}");
4546        assert!(rendered.contains("Retrying in 120s"), "{rendered}");
4547        assert!(!brief.has_outlasted_the_queue());
4548
4549        let long = brief.since(GITHUB_CANCELS_QUEUED_JOBS_AFTER + Duration::from_secs(1));
4550        assert!(long.has_outlasted_the_queue());
4551        assert!(
4552            long.to_string().contains("queued work has been lost"),
4553            "{long}"
4554        );
4555
4556        // "We cannot tell" is not "not yet".
4557        assert!(!OfflineState::new(9, Duration::from_secs(60)).has_outlasted_the_queue());
4558    }
4559
4560    // =======================================================================
4561    // Offline, end to end
4562    // =======================================================================
4563
4564    /// `e1`'s Definition of Done: *"An unreachable GitHub yields `offline`, zero
4565    /// new runners, retained existing processes, and jittered backoff; recovery
4566    /// resumes polling and does not double-count a job that was already being
4567    /// served."*
4568    #[tokio::test]
4569    async fn an_unreachable_github_starts_nothing_retains_everything_and_backs_off() {
4570        let live = vec![
4571            attempt_in(AttemptState::Busy, 1, 1),
4572            attempt_in(AttemptState::Starting, 2, 1),
4573        ];
4574        let launcher = Arc::new(FakeLauncher::new().seeded(live.clone()));
4575        let demand = Arc::new(FakeDemand::failing(RefreshState::Offline));
4576        let mut harness = Harness::build(
4577            host_with(8),
4578            Arc::clone(&launcher),
4579            Arc::clone(&demand),
4580            Arc::new(InProcessAllocationLock::new()),
4581        );
4582        let policy = policy(1, "acme/app", 8);
4583
4584        let report = harness
4585            .reconciler
4586            .reconcile(std::slice::from_ref(&policy))
4587            .await;
4588
4589        assert!(report.is_offline());
4590        assert_eq!(report.started, 0, "no new runner during an outage");
4591        assert_eq!(launcher.launches(), 0);
4592        assert_eq!(
4593            launcher.snapshot(),
4594            live,
4595            "existing runner processes are retained, untouched"
4596        );
4597        assert_eq!(report.unreadable, vec![PolicyId::from_u128(1)]);
4598        assert_eq!(report.next_poll.pace, PollPace::Offline { consecutive: 1 });
4599        assert!(report.next_poll.delay >= Duration::from_secs(60));
4600        let offline = report.offline_state().expect("an offline state to display");
4601        assert!(offline.to_string().contains("24 hours"));
4602
4603        // Recovery: the same job is still queued, and one runner is already
4604        // serving it. Demand is recomputed from the current queued set rather
4605        // than accumulated, so the reconnect starts nothing new.
4606        demand.set(PollOutcome::Ready(QueuedDemand::of(
4607            repo("acme/app"),
4608            jobs(2),
4609        )));
4610        let recovered = harness.reconciler.reconcile(&[policy]).await;
4611
4612        assert!(!recovered.is_offline());
4613        assert_eq!(recovered.next_poll.pace, PollPace::Nominal);
4614        assert_eq!(
4615            recovered.started, 0,
4616            "two queued runs, two attempts already in flight: a reconnect cannot \
4617             double-count work"
4618        );
4619        assert_eq!(recovered.allocations[0].active_owned, 2);
4620        assert_eq!(launcher.live_count(), 2);
4621    }
4622
4623    /// One unreachable target must not idle a whole host.
4624    ///
4625    /// The failure that decides the *schedule* is the most severe across every
4626    /// target polled — backing the whole loop off during an outage is the safe
4627    /// error, and `f3` runs one reconciler per target anyway, so in production
4628    /// the two are usually the same thing. What must not follow from that is
4629    /// refusing to serve a policy whose own target answered perfectly well, and
4630    /// the two are easy to conflate because the offline reading is sitting in
4631    /// the same map.
4632    #[tokio::test]
4633    async fn one_offline_target_does_not_stop_a_reachable_one() {
4634        let mut harness = Harness::simple(8, 0, "acme/app");
4635        harness.demand.set_for(
4636            &ScaleTarget::repository("acme/app").unwrap(),
4637            PollOutcome::Ready(QueuedDemand::of(repo("acme/app"), jobs(2))),
4638        );
4639        harness.demand.set_for(
4640            &ScaleTarget::repository("acme/broken").unwrap(),
4641            PollOutcome::Failed(RefreshState::Offline),
4642        );
4643
4644        let report = harness
4645            .reconciler
4646            .reconcile(&[policy(1, "acme/app", 4), policy(2, "acme/broken", 4)])
4647            .await;
4648
4649        assert_eq!(
4650            report.started, 2,
4651            "the reachable target was served; an unreachable sibling repository must not \
4652             idle the host"
4653        );
4654        assert_eq!(report.unreadable, vec![PolicyId::from_u128(2)]);
4655        assert_eq!(harness.demand.polls().len(), 2, "both targets were polled");
4656
4657        // And the schedule takes the worse of the two.
4658        assert!(report.is_offline());
4659        assert_eq!(report.next_poll.pace, PollPace::Offline { consecutive: 1 });
4660    }
4661
4662    /// The 24-hour bound has to be reachable in production, not only in a unit
4663    /// test of [`OfflineState`].
4664    ///
4665    /// This was a real gap: the reconciler built its offline state from the
4666    /// back-off count alone, so `offline_for` was always `None` and
4667    /// [`OfflineState::has_outlasted_the_queue`] could never be true outside a
4668    /// test that constructed the value by hand. An operator whose agent had been
4669    /// offline for two days would have been told that an outage longer than 24
4670    /// hours *would* lose queued work, in the future tense, having already lost
4671    /// it.
4672    ///
4673    /// The elapsed time is measured from the first poll of the run rather than
4674    /// derived from the interval, because the back-off doubles and the two
4675    /// diverge immediately.
4676    #[tokio::test]
4677    async fn a_day_long_outage_says_that_queued_work_has_already_been_lost() {
4678        let clock = Arc::new(FakeClock::default());
4679        let launcher = Arc::new(FakeLauncher::new());
4680        let demand = Arc::new(FakeDemand::failing(RefreshState::Offline));
4681        let mut reconciler = Reconciler::new(
4682            host_with(4),
4683            ReconcilerPorts {
4684                demand: Arc::clone(&demand) as Arc<dyn DemandSource>,
4685                launcher: Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
4686                lock: Arc::new(InProcessAllocationLock::new()),
4687                directory: Arc::new(FakeDirectory::default()),
4688                clock: Arc::clone(&clock) as Arc<dyn Clock>,
4689                jitter: Arc::new(NoJitter),
4690                events: Arc::new(NoEvents),
4691            },
4692        );
4693        let policy = policy(1, "acme/app", 4);
4694
4695        // The outage begins.
4696        let first = reconciler.reconcile(std::slice::from_ref(&policy)).await;
4697        let state = first.offline_state().expect("an offline state");
4698        assert!(!state.has_outlasted_the_queue());
4699        assert!(
4700            state
4701                .to_string()
4702                .contains("an outage longer than that loses"),
4703            "{state}"
4704        );
4705
4706        // A day and a minute later, still unreachable.
4707        clock.advance_secs(24 * 60 * 60 + 60);
4708        let later = reconciler.reconcile(std::slice::from_ref(&policy)).await;
4709        let state = later.offline_state().expect("an offline state");
4710        assert!(state.has_outlasted_the_queue());
4711        assert!(
4712            state.to_string().contains("queued work has been lost"),
4713            "{state}"
4714        );
4715        assert_eq!(launcher.launches(), 0, "and still nothing was started");
4716
4717        // Recovery closes the run, so a *later* outage measures from itself
4718        // rather than from the first one.
4719        demand.set(PollOutcome::Ready(QueuedDemand::of(
4720            repo("acme/app"),
4721            jobs(0),
4722        )));
4723        let recovered = reconciler.reconcile(std::slice::from_ref(&policy)).await;
4724        assert!(recovered.offline_state().is_none());
4725        assert_eq!(reconciler.schedule().offline_for(clock.now()), None);
4726
4727        demand.set(PollOutcome::Failed(RefreshState::Offline));
4728        let again = reconciler.reconcile(std::slice::from_ref(&policy)).await;
4729        assert!(
4730            !again
4731                .offline_state()
4732                .expect("an offline state")
4733                .has_outlasted_the_queue(),
4734            "a new outage must not inherit the age of the one before it"
4735        );
4736    }
4737
4738    /// Finding 5: the adapter, not the lock underneath it.
4739    ///
4740    /// `d1` covers `LockKind::Allocation` including a contended `acquire_at`
4741    /// with a wait. What that does not reach is this adapter: the
4742    /// `spawn_blocking` wrapper, the collapse of both a refused lock and a
4743    /// panicked blocking task into `AllocationLockBusy`, and — the one that
4744    /// would be silent — whether [`AllocationGuard`] really holds the
4745    /// `HostLock`, since dropping it is the only release there is. A guard that
4746    /// dropped the lock on the way out would make every acquisition succeed and
4747    /// the ceiling would hold by luck.
4748    ///
4749    /// The original disclosure said this needed a real filesystem and was
4750    /// therefore expensive. `AppPaths::rooted_at` plus `tempfile` — already a
4751    /// non-dev dependency of this crate — makes it about fifteen lines, so the
4752    /// reason was weaker than stated.
4753    #[tokio::test]
4754    async fn the_file_allocation_lock_excludes_a_second_holder_and_releases_on_drop() {
4755        let root = tempfile::tempdir().expect("a temporary directory");
4756        let paths = Arc::new(runner_manager_platform::paths::AppPaths::rooted_at(
4757            root.path(),
4758        ));
4759        let lock = FileAllocationLock::new(paths).with_wait(Duration::from_millis(50));
4760
4761        let held = lock.acquire().await.expect("an uncontended lock is free");
4762        assert!(
4763            matches!(lock.acquire().await, Err(AllocationLockBusy)),
4764            "a second holder was admitted; on Unix the lock is per open file description \
4765             and on Windows the share mode denies write, so this must be refused even \
4766             from inside the same process"
4767        );
4768
4769        drop(held);
4770        let regained = lock.acquire().await;
4771        assert!(
4772            regained.is_ok(),
4773            "dropping the guard is the only release there is, so a guard that does not \
4774             hold the `HostLock` leaves it held forever"
4775        );
4776    }
4777
4778    #[test]
4779    fn tee_events_reaches_both_sinks() {
4780        // `f3` wires the log sink and `g2`'s buffer at once, and an event that
4781        // reached only one of them would be an activity view missing lines the
4782        // log file has, or the reverse.
4783        let left = Arc::new(EventLog::new());
4784        let right = Arc::new(EventLog::new());
4785        let tee = TeeEvents(
4786            Arc::clone(&left) as Arc<dyn EventSink>,
4787            Arc::clone(&right) as Arc<dyn EventSink>,
4788        );
4789
4790        tee.emit(LifecycleEvent::MonitorOnlySkipped {
4791            policy: PolicyId::from_u128(1),
4792        });
4793
4794        assert_eq!(left.count_of("monitor_only_skipped"), 1);
4795        assert_eq!(right.count_of("monitor_only_skipped"), 1);
4796    }
4797
4798    // =======================================================================
4799    // Budget: the repository list, and the per-target poll
4800    // =======================================================================
4801
4802    #[tokio::test]
4803    async fn the_repository_list_refreshes_far_more_slowly_than_the_demand_poll() {
4804        let clock = Arc::new(FakeClock::default());
4805        let directory = Arc::new(FakeDirectory::of(vec![repo("acme/one"), repo("acme/two")]));
4806        let cache = RepositoryCache::new(
4807            Arc::clone(&directory) as Arc<dyn RepositoryDirectory>,
4808            Arc::clone(&clock) as Arc<dyn Clock>,
4809            RefreshInterval::default(),
4810        );
4811        let target = ScaleTarget::organization("acme").unwrap();
4812
4813        assert_eq!(
4814            cache.ttl(),
4815            Duration::from_secs(60 * u64::from(REPOSITORY_LIST_REFRESH_MULTIPLE))
4816        );
4817
4818        // Every poll inside the window reuses the list.
4819        for _ in 0..REPOSITORY_LIST_REFRESH_MULTIPLE {
4820            let scope = cache.scope_for(&target).await.unwrap();
4821            assert_eq!(scope.repositories().len(), 2);
4822            clock.advance_secs(60);
4823        }
4824        assert_eq!(
4825            directory.calls(),
4826            1,
4827            "re-listing an organization at demand-poll frequency is what exhausts the \
4828             shared request budget"
4829        );
4830        assert_eq!(cache.lookups(), 1);
4831
4832        // Past it, exactly one more.
4833        cache.scope_for(&target).await.unwrap();
4834        assert_eq!(directory.calls(), 2);
4835    }
4836
4837    #[tokio::test]
4838    async fn a_repository_target_never_consults_the_directory() {
4839        let directory = Arc::new(FakeDirectory::of(vec![repo("acme/other")]));
4840        let cache = RepositoryCache::new(
4841            Arc::clone(&directory) as Arc<dyn RepositoryDirectory>,
4842            Arc::new(FakeClock::default()) as Arc<dyn Clock>,
4843            RefreshInterval::default(),
4844        );
4845        let target = ScaleTarget::repository("acme/app").unwrap();
4846
4847        let scope = cache.scope_for(&target).await.unwrap();
4848        assert_eq!(scope.repositories(), &[repo("acme/app")]);
4849        assert_eq!(directory.calls(), 0);
4850    }
4851
4852    #[tokio::test]
4853    async fn two_policies_on_one_target_cost_one_demand_poll_not_two() {
4854        // `04-subsystem-contracts.md` prices a *target*. A loop that spent per
4855        // policy would exceed the projection `f2` admitted the configuration
4856        // against, silently.
4857        let mut harness = Harness::simple(8, 4, "o/r");
4858        harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
4859            repo("o/r"),
4860            [
4861                fixtures::queued_job(&[HOST_LABEL]),
4862                fixtures::queued_job(&[HOST_LABEL]),
4863                fixtures::queued_job(&["rm-home-win-x64-py-isolated"]),
4864                fixtures::queued_job(&["rm-home-win-x64-py-isolated"]),
4865            ],
4866        )));
4867        let mut isolated = fixtures::named_policy("py-isolated", PolicyId::from_u128(2));
4868        isolated.activate().unwrap();
4869        let report = harness
4870            .reconciler
4871            .reconcile(&[policy(1, "o/r", 2), isolated])
4872            .await;
4873
4874        assert_eq!(harness.demand.polls().len(), 1);
4875        assert_eq!(report.targets_read, 1, "profiles share one target reading");
4876        assert_eq!(
4877            report.demand_requests,
4878            runner_manager_github::demand::DEMAND_REQUESTS_PER_REPOSITORY_PER_POLL,
4879            "one repository's worth of demand requests, not two policies' worth. Read              from the constant rather than written as a literal so that repricing the              poll cannot silently turn this into an assertion about the wrong thing"
4880        );
4881        assert_eq!(
4882            report.started, 4,
4883            "native and isolated profiles each get two"
4884        );
4885    }
4886
4887    #[tokio::test]
4888    async fn subset_labels_without_selector_scale_only_the_compatible_local_profile() {
4889        use runner_manager_domain::model::{Arch, HostLabel, Os};
4890        use runner_manager_domain::policy::RoutingLabels;
4891        let mut harness = Harness::build(
4892            fixtures::host()
4893                .os(Os::MacOs)
4894                .architecture(Arch::Arm64)
4895                .capacity(10)
4896                .build(),
4897            Arc::new(FakeLauncher::new()),
4898            Arc::new(FakeDemand::ready(0, &repo("o/r"))),
4899            Arc::new(InProcessAllocationLock::new()),
4900        );
4901        harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
4902            repo("o/r"),
4903            [
4904                fixtures::queued_job(&["self-hosted", "macOS", "ARM64"]),
4905                fixtures::queued_job(&["SELF-HOSTED", "MACOS", "arm64"]),
4906                fixtures::queued_job(&["self-hosted", "macOS", "X64"]),
4907                fixtures::queued_job(&["self-hosted", "Windows", "ARM64"]),
4908                fixtures::queued_job(&["self-hosted", "macOS", "ARM64", "gpu"]),
4909                fixtures::queued_job(&["rm-other-osx-arm64", "macOS", "ARM64"]),
4910            ],
4911        )));
4912        let mut local = fixtures::policy()
4913            .id(PolicyId::from_u128(1))
4914            .repository("o/r")
4915            .autoscale("macmini", 10)
4916            .routing_labels(RoutingLabels::derive(
4917                &HostLabel::new("macmini").unwrap(),
4918                Os::MacOs,
4919                Arch::Arm64,
4920            ))
4921            .active()
4922            .build();
4923        for label in ["self-hosted", "macos", "arm64", "extra"] {
4924            local.add_routing_label(fixtures::label(label)).unwrap();
4925        }
4926        let mut foreign = local.to_persisted();
4927        foreign.id = PolicyId::from_u128(2);
4928        foreign.host_id = HostId::from_u128(0xdead);
4929        let foreign = ScalePolicy::from_persisted(foreign).unwrap();
4930        let report = harness.reconciler.reconcile(&[local, foreign]).await;
4931        assert_eq!(report.started, 2);
4932        assert_eq!(report.targets_read, 1);
4933        assert!(harness.events.events().iter().any(|event| matches!(
4934            event,
4935            LifecycleEvent::DemandObserved {
4936                demand: 2,
4937                not_matched: 4,
4938                ..
4939            }
4940        )));
4941        assert!(
4942            !harness
4943                .events
4944                .events()
4945                .iter()
4946                .any(|event| matches!(event, LifecycleEvent::RoutingRefused { .. }))
4947        );
4948    }
4949
4950    #[tokio::test]
4951    async fn isolated_overlap_never_falls_back_to_native_in_either_state_or_order() {
4952        use runner_manager_domain::execution::{
4953            Backend, ExecutionPolicy, ImageReference, ResourceLimits,
4954        };
4955        let mut native = policy(1, "o/r", 2);
4956        native
4957            .add_routing_label(fixtures::label("self-hosted"))
4958            .unwrap();
4959        let mut isolated = fixtures::named_policy("py-isolated", PolicyId::from_u128(2));
4960        isolated
4961            .add_routing_label(fixtures::label("self-hosted"))
4962            .unwrap();
4963        isolated
4964            .set_execution_policy(ExecutionPolicy::Isolated {
4965                backend: Backend::Oci,
4966                image: ImageReference::new(format!(
4967                    "registry.example/runner@sha256:{}",
4968                    "a".repeat(64)
4969                ))
4970                .unwrap(),
4971                resources: ResourceLimits {
4972                    cpu_millis: 1000,
4973                    memory_mib: 512,
4974                    disk_mib: 1024,
4975                },
4976            })
4977            .unwrap();
4978        assert!(!isolated.may_start_runners());
4979        let mut active_isolated = isolated.clone();
4980        active_isolated.activate().unwrap();
4981        for profiles in [
4982            [native.clone(), isolated.clone()],
4983            [isolated.clone(), native.clone()],
4984            [native.clone(), active_isolated.clone()],
4985            [active_isolated, native.clone()],
4986        ] {
4987            let mut harness = Harness::simple(4, 0, "o/r");
4988            harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
4989                repo("o/r"),
4990                [fixtures::queued_job(&["SELF-HOSTED"])],
4991            )));
4992            let report = harness.reconciler.reconcile(&profiles).await;
4993            assert_eq!(report.started, 0);
4994            assert!(harness.events.events().iter().any(|event| matches!(
4995                event,
4996                LifecycleEvent::RoutingRefused {
4997                    reason: RoutingRefusal::OverlappingProfiles,
4998                    count: 1,
4999                    ..
5000                }
5001            )));
5002        }
5003        // A job explicitly requiring the unavailable isolated selector cannot
5004        // be served by the native sibling either.
5005        let mut harness = Harness::simple(4, 0, "o/r");
5006        harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
5007            repo("o/r"),
5008            [fixtures::queued_job(&["rm-home-win-x64-py-isolated"])],
5009        )));
5010        assert_eq!(
5011            harness
5012                .reconciler
5013                .reconcile(&[native, isolated])
5014                .await
5015                .started,
5016            0
5017        );
5018    }
5019
5020    #[tokio::test]
5021    async fn shared_optional_labels_do_not_duplicate_native_and_isolated_demand() {
5022        let mut harness = Harness::simple(2, 0, "o/r");
5023        harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
5024            repo("o/r"),
5025            [
5026                fixtures::queued_job(&[HOST_LABEL, "gpu"]),
5027                fixtures::queued_job(&["rm-home-win-x64-py-isolated", "gpu"]),
5028                fixtures::queued_job(&["gpu"]),
5029                fixtures::unresolvable_job(),
5030            ],
5031        )));
5032        let mut native = policy(1, "o/r", 2);
5033        native.add_routing_label(fixtures::label("gpu")).unwrap();
5034        let mut isolated = fixtures::named_policy("py-isolated", PolicyId::from_u128(2));
5035        isolated.add_routing_label(fixtures::label("gpu")).unwrap();
5036        isolated.activate().unwrap();
5037
5038        let report = harness.reconciler.reconcile(&[native, isolated]).await;
5039        assert_eq!(harness.demand.polls().len(), 1);
5040        assert_eq!(report.targets_read, 1);
5041        assert_eq!(report.started, 2);
5042        assert_eq!(report.allocations.len(), 2);
5043        assert!(
5044            report
5045                .allocations
5046                .iter()
5047                .all(|allocation| allocation.to_start == 1)
5048        );
5049        assert!(harness.events.events().iter().any(|event| matches!(
5050            event,
5051            LifecycleEvent::RoutingRefused {
5052                reason: RoutingRefusal::OverlappingProfiles,
5053                count: 1,
5054                ..
5055            }
5056        )));
5057        assert_eq!(
5058            harness
5059                .events
5060                .events()
5061                .iter()
5062                .filter(|event| matches!(
5063                    event,
5064                    LifecycleEvent::DemandObserved {
5065                        unresolvable: 1,
5066                        ..
5067                    }
5068                ))
5069                .count(),
5070            2,
5071            "an expression stays unresolvable for both profiles"
5072        );
5073    }
5074
5075    #[tokio::test]
5076    async fn missing_required_selector_labels_do_not_match_and_overlaps_show_remedies() {
5077        let mut harness = Harness::simple(4, 0, "o/r");
5078        harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
5079            repo("o/r"),
5080            [fixtures::queued_job(&[
5081                HOST_LABEL,
5082                "rm-home-win-x64-py-isolated",
5083            ])],
5084        )));
5085        let mut isolated = fixtures::named_policy("py-isolated", PolicyId::from_u128(2));
5086        isolated.activate().unwrap();
5087        let report = harness
5088            .reconciler
5089            .reconcile(&[policy(1, "o/r", 2), isolated])
5090            .await;
5091        assert_eq!(report.started, 0);
5092        assert_eq!(
5093            harness
5094                .events
5095                .events()
5096                .iter()
5097                .filter(|event| matches!(
5098                    event,
5099                    LifecycleEvent::DemandObserved {
5100                        demand: 0,
5101                        not_matched: 1,
5102                        ..
5103                    }
5104                ))
5105                .count(),
5106            2
5107        );
5108
5109        // Domain constructors permit extra selector-shaped labels. Routing
5110        // depends on all required labels and unique ownership, not their shape.
5111        // The store separately rejects sibling selector reuse on write/load.
5112        let mut superset = policy(1, "o/r", 2);
5113        assert!(
5114            superset
5115                .add_routing_label(fixtures::label("rm-home-win-x64-py-isolated"))
5116                .unwrap()
5117        );
5118        let mut sibling = fixtures::named_policy("py-isolated", PolicyId::from_u128(2));
5119        sibling.activate().unwrap();
5120        let mut unique = Harness::simple(4, 0, "o/r");
5121        unique.demand.set(PollOutcome::Ready(QueuedDemand::of(
5122            repo("o/r"),
5123            [fixtures::queued_job(&[
5124                HOST_LABEL,
5125                "rm-home-win-x64-py-isolated",
5126            ])],
5127        )));
5128        let report = unique.reconciler.reconcile(&[superset, sibling]).await;
5129        assert_eq!(report.started, 1);
5130        assert_eq!(
5131            report
5132                .allocations
5133                .iter()
5134                .find(|allocation| allocation.to_start == 1)
5135                .unwrap()
5136                .policy_id,
5137            PolicyId::from_u128(1)
5138        );
5139        assert!(
5140            !unique
5141                .events
5142                .events()
5143                .iter()
5144                .any(|event| matches!(event, LifecycleEvent::RoutingRefused { .. }))
5145        );
5146
5147        let mut overlap = Harness::simple(4, 1, "o/r");
5148        let report = overlap
5149            .reconciler
5150            .reconcile(&[policy(1, "o/r", 2), policy(2, "o/r", 2)])
5151            .await;
5152        assert_eq!(report.started, 0);
5153        assert!(overlap.events.events().iter().any(|event| matches!(
5154            event,
5155            LifecycleEvent::RoutingRefused {
5156                reason: RoutingRefusal::OverlappingProfiles,
5157                count: 1,
5158                ..
5159            }
5160        )));
5161        assert!(
5162            RoutingRefusal::OverlappingProfiles
5163                .to_string()
5164                .contains("repair")
5165        );
5166    }
5167
5168    // =======================================================================
5169    // Failure paths
5170    // =======================================================================
5171
5172    #[tokio::test]
5173    async fn a_failed_launch_stops_the_run_and_is_reported_without_free_text() {
5174        let launcher = Arc::new(FakeLauncher::new());
5175        launcher.fail_next(FailureReason::Other("token ghp_0123456789abcdef".into()));
5176        let mut harness = Harness::build(
5177            host_with(4),
5178            Arc::clone(&launcher),
5179            Arc::new(FakeDemand::ready(3, &repo("acme/app"))),
5180            Arc::new(InProcessAllocationLock::new()),
5181        );
5182
5183        let report = harness
5184            .reconciler
5185            .reconcile(&[policy(1, "acme/app", 4)])
5186            .await;
5187        assert_eq!(report.started, 0);
5188        assert_eq!(report.allocations[0].to_start, 3, "the decision stands");
5189
5190        let failures: Vec<&'static str> = harness
5191            .events
5192            .events()
5193            .into_iter()
5194            .filter_map(|event| match event {
5195                LifecycleEvent::RunnerStartFailed { reason, .. } => Some(reason),
5196                _ => None,
5197            })
5198            .collect();
5199        assert_eq!(failures, vec!["other"]);
5200        assert!(
5201            !failures[0].contains("ghp_"),
5202            "an event carried a `FailureReason::Other` detail verbatim"
5203        );
5204    }
5205
5206    /// The host-fitness preflight: while a required host check fails, the pass
5207    /// still decides what it would start, but nothing reaches the inner
5208    /// launcher -- so nothing is registered with GitHub -- and the event names
5209    /// `host_unfit`. Clearing the flag lets the next pass start runners.
5210    #[tokio::test]
5211    async fn an_unfit_host_registers_no_runner_and_reports_host_unfit() {
5212        let launcher = Arc::new(FakeLauncher::new());
5213        let unfit = Arc::new(AtomicBool::new(true));
5214        let events = Arc::new(EventLog::new());
5215        let mut reconciler = Reconciler::new(
5216            host_with(4),
5217            ReconcilerPorts {
5218                demand: Arc::new(FakeDemand::ready(2, &repo("acme/app"))),
5219                launcher: Arc::new(FitnessGatedLauncher::new(
5220                    Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
5221                    Arc::clone(&unfit),
5222                )),
5223                lock: Arc::new(InProcessAllocationLock::new()),
5224                directory: Arc::new(FakeDirectory::default()),
5225                clock: Arc::new(FakeClock::default()) as Arc<dyn Clock>,
5226                jitter: Arc::new(NoJitter) as Arc<dyn Jitter>,
5227                events: Arc::clone(&events) as Arc<dyn EventSink>,
5228            },
5229        );
5230        let policies = [policy(1, "acme/app", 4)];
5231
5232        let report = reconciler.reconcile(&policies).await;
5233        assert_eq!(report.started, 0);
5234        assert_eq!(launcher.launches(), 0, "the inner launcher must not run");
5235        let reasons: Vec<&'static str> = events
5236            .events()
5237            .into_iter()
5238            .filter_map(|event| match event {
5239                LifecycleEvent::RunnerStartFailed { reason, .. } => Some(reason),
5240                _ => None,
5241            })
5242            .collect();
5243        assert_eq!(reasons, ["host_unfit"]);
5244
5245        unfit.store(false, Ordering::Release);
5246        let report = reconciler.reconcile(&policies).await;
5247        assert_eq!(report.started, 2);
5248        assert_eq!(launcher.launches(), 2);
5249    }
5250
5251    #[tokio::test]
5252    async fn a_held_allocation_lock_starts_nothing_and_says_so() {
5253        let launcher = Arc::new(FakeLauncher::new());
5254        let mut harness = Harness::build(
5255            host_with(4),
5256            Arc::clone(&launcher),
5257            Arc::new(FakeDemand::ready(3, &repo("acme/app"))),
5258            Arc::new(HeldLock),
5259        );
5260
5261        let report = harness
5262            .reconciler
5263            .reconcile(&[policy(1, "acme/app", 4)])
5264            .await;
5265        assert_eq!(report.started, 0);
5266        assert_eq!(
5267            report.deferred, 3,
5268            "three runners were granted and none was created; `deferred` counts grants, \
5269             not policies -- it reported `1` when a policy that launched two of five and \
5270             then lost the lock had left three unstarted"
5271        );
5272        assert_eq!(launcher.launches(), 0);
5273        assert_eq!(harness.events.count_of("allocation_deferred"), 1);
5274        assert!(
5275            harness
5276                .events
5277                .events()
5278                .iter()
5279                .any(|event| matches!(event, LifecycleEvent::AllocationDeferred { count: 3, .. })),
5280            "the event carries the same number the report does"
5281        );
5282        assert_eq!(
5283            report.allocations.len(),
5284            1,
5285            "the intent is still reported, so an operator staring at a queue sees why \
5286             nothing started"
5287        );
5288    }
5289
5290    #[tokio::test]
5291    async fn a_foreign_or_draining_policy_is_reported_by_name_and_polls_nothing() {
5292        let mut harness = Harness::simple(8, 5, "acme/app");
5293
5294        let foreign = fixtures::policy()
5295            .id(PolicyId::from_u128(1))
5296            .repository("acme/app")
5297            .host(HostId::from_u128(0xdead))
5298            .autoscale("office", 4)
5299            .active()
5300            .build();
5301        let mut draining = policy(2, "acme/app", 4);
5302        draining.request_disable().unwrap();
5303
5304        let report = harness.reconciler.reconcile(&[foreign, draining]).await;
5305
5306        assert_eq!(report.started, 0);
5307        assert_eq!(
5308            harness.demand.polls().len(),
5309            0,
5310            "neither can act on an answer"
5311        );
5312        let factors: Vec<LimitingFactor> = report
5313            .allocations
5314            .iter()
5315            .map(|a| a.limiting_factor)
5316            .collect();
5317        assert!(factors.contains(&LimitingFactor::ForeignHost));
5318        assert!(factors.contains(&LimitingFactor::NotReconciling));
5319    }
5320
5321    #[tokio::test]
5322    async fn an_unreadable_repository_list_makes_the_target_unreadable_not_empty() {
5323        // Polling a scope nobody chose would report a demand number for the
5324        // wrong set of repositories, which is worse than reporting nothing.
5325        #[derive(Debug)]
5326        struct BrokenDirectory;
5327
5328        #[async_trait::async_trait]
5329        impl RepositoryDirectory for BrokenDirectory {
5330            async fn repositories(&self, _org: &Org) -> Result<Vec<OwnerRepo>, InventoryError> {
5331                Err(InventoryError::Cancelled)
5332            }
5333        }
5334
5335        let launcher = Arc::new(FakeLauncher::new());
5336        let events = Arc::new(EventLog::new());
5337        let mut reconciler = Reconciler::new(
5338            host_with(4),
5339            ReconcilerPorts {
5340                demand: Arc::new(FakeDemand::default()),
5341                launcher: Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
5342                lock: Arc::new(InProcessAllocationLock::new()),
5343                directory: Arc::new(BrokenDirectory),
5344                clock: Arc::new(FakeClock::default()),
5345                jitter: Arc::new(NoJitter),
5346                events: Arc::clone(&events) as Arc<dyn EventSink>,
5347            },
5348        );
5349
5350        let org_policy = fixtures::policy()
5351            .id(PolicyId::from_u128(1))
5352            .organization("acme")
5353            .autoscale("home", 4)
5354            .active()
5355            .build();
5356
5357        let report = reconciler.reconcile(&[org_policy]).await;
5358        assert_eq!(report.started, 0);
5359        assert_eq!(report.unreadable, vec![PolicyId::from_u128(1)]);
5360        assert_eq!(events.count_of("target_unreadable"), 1);
5361    }
5362
5363    // =======================================================================
5364    // What the events may carry
5365    // =======================================================================
5366
5367    /// One value of every [`LifecycleEvent`] variant.
5368    ///
5369    /// Hand-written, and what keeps it honest is the wildcard-free `match` in
5370    /// [`LifecycleEvent::name`]: adding a variant stops that compiling and puts
5371    /// the author here. The same residual `b1` records for `FailureReason::ALL`
5372    /// applies — an author who writes the `name` arm and forgets this list gets
5373    /// a green suite with the variant unscanned.
5374    fn every_event() -> Vec<LifecycleEvent> {
5375        let policy = PolicyId::from_u128(0xabcd_ef01);
5376        let attempt = AttemptId::from_u128(0x1234_5678);
5377        vec![
5378            LifecycleEvent::DemandObserved {
5379                policy,
5380                demand: u32::MAX,
5381                not_matched: u32::MAX,
5382                unresolvable: u32::MAX,
5383                complete: false,
5384            },
5385            LifecycleEvent::TargetUnreadable {
5386                policy,
5387                reason: unreadable_reason(&RefreshState::Failed {
5388                    status: Some(500),
5389                    message: "Authorization: Bearer ghp_0123456789abcdefghijklmnopqrstuvwxyz"
5390                        .into(),
5391                }),
5392            },
5393            LifecycleEvent::Allocated {
5394                policy,
5395                demand: u32::MAX,
5396                desired: u16::MAX,
5397                active_owned: 7,
5398                headroom: 9,
5399                to_start: 2,
5400                limiting: LimitingFactor::HostCapacity,
5401            },
5402            LifecycleEvent::MonitorOnlySkipped { policy },
5403            LifecycleEvent::RunnerStarted { policy, attempt },
5404            LifecycleEvent::RunnerStartFailed {
5405                policy,
5406                reason: failure_reason_kind(&FailureReason::Other(
5407                    "Authorization: Bearer ghp_0123456789abcdefghijklmnopqrstuvwxyz".into(),
5408                )),
5409            },
5410            LifecycleEvent::AllocationDeferred { policy, count: 4 },
5411            LifecycleEvent::AttemptsUnreadable {
5412                reason: failure_reason_kind(&FailureReason::Other(
5413                    "x-api-key: ghp_0123456789abcdefghijklmnopqrstuvwxyz".into(),
5414                )),
5415            },
5416            LifecycleEvent::AttemptCleanFailed {
5417                policy,
5418                attempt,
5419                reason: failure_reason_kind(&FailureReason::ProcessExitedUnexpectedly),
5420            },
5421            LifecycleEvent::AttemptCleaned {
5422                policy,
5423                attempt,
5424                outcome: OutcomeKind::IdleExit,
5425            },
5426            LifecycleEvent::ScaleDownRefused { policy, attempt },
5427            LifecycleEvent::PollScheduled {
5428                retry_in_ms: 900_000,
5429                pace: PollPace::RateLimited {
5430                    kind: RateLimitKind::Primary,
5431                },
5432            },
5433        ]
5434    }
5435
5436    /// `e1`'s Definition of Done: *"No emitted event contains a token, a JIT
5437    /// blob, or a credential header."*
5438    ///
5439    /// Asserted by rendering every variant and putting the result through `d1`'s
5440    /// own scrubber: if any of it looked like a credential to the redactor that
5441    /// guards the log file, the round trip would not be the identity. The
5442    /// positive control at the bottom is what stops that assertion passing
5443    /// because the scrubber is asleep.
5444    #[test]
5445    fn no_emitted_event_can_carry_a_credential() {
5446        use runner_manager_platform::logging::redact;
5447
5448        for event in every_event() {
5449            let displayed = event.to_string();
5450            assert_eq!(
5451                redact(&displayed),
5452                displayed,
5453                "`{}` renders something `d1`'s sink would have to redact",
5454                event.name()
5455            );
5456
5457            let debugged = format!("{event:?}");
5458            assert_eq!(
5459                redact(&debugged),
5460                debugged,
5461                "`{}`'s Debug renders something `d1`'s sink would have to redact",
5462                event.name()
5463            );
5464        }
5465
5466        // The control: the scrubber is awake, and would have caught a credential
5467        // had one been there.
5468        let secret = "Authorization: Bearer ghp_0123456789abcdefghijklmnopqrstuvwxyz";
5469        assert_ne!(
5470            redact(secret),
5471            secret,
5472            "the scan above proves nothing if `redact` no longer recognises a credential"
5473        );
5474    }
5475
5476    #[test]
5477    fn every_field_name_this_sink_emits_is_one_d1_allows() {
5478        use runner_manager_platform::logging::is_field_allowed;
5479
5480        // The names `TracingEvents` writes. Kept beside the sink rather than
5481        // derived from it, because a derived list would move with the code and
5482        // assert nothing.
5483        for field in [
5484            "event",
5485            "policy_id",
5486            "attempt_id",
5487            "attempt_state",
5488            "demand",
5489            "desired",
5490            "capacity",
5491            "headroom",
5492            "count",
5493            "reason",
5494            "outcome",
5495            "mode",
5496            "lock",
5497            "retry_in_ms",
5498            "state",
5499        ] {
5500            assert!(
5501                is_field_allowed(field),
5502                "`{field}` is not on `d1`'s allow-list, so this sink would emit \
5503                 `[redacted]` in its place and the line would lose its meaning"
5504            );
5505        }
5506    }
5507
5508    #[test]
5509    fn every_failure_reason_has_a_credential_free_kind() {
5510        for reason in FailureReason::ALL {
5511            let kind = failure_reason_kind(&reason);
5512            assert!(!kind.is_empty());
5513            assert!(
5514                kind.chars().all(|c| c.is_ascii_lowercase() || c == '_'),
5515                "`{kind}` is not a fixed identifier"
5516            );
5517        }
5518        assert_eq!(
5519            failure_reason_kind(&FailureReason::Other("ghp_secret".into())),
5520            "other",
5521            "the detail of an `Other` reason never reaches an event"
5522        );
5523        let categories = [
5524            IsolationProviderFailure::NotInstalled,
5525            IsolationProviderFailure::PermissionDenied,
5526            IsolationProviderFailure::ImageUnavailableOrIncompatible,
5527            IsolationProviderFailure::DiskQuotaUnavailable,
5528            IsolationProviderFailure::RuntimeOperationFailed,
5529        ];
5530        let mut kinds = std::collections::BTreeSet::new();
5531        for category in categories {
5532            let kind = failure_reason_kind(&FailureReason::IsolationProvider(category));
5533            assert_ne!(kind, "other");
5534            assert!(!kind.contains("secret"));
5535            kinds.insert(kind);
5536        }
5537        assert_eq!(kinds.len(), categories.len());
5538    }
5539
5540    // =======================================================================
5541    // The two tripwires
5542    // =======================================================================
5543
5544    /// One source file's production half, with comment lines dropped.
5545    ///
5546    /// Both exclusions are `c4`'s, and load-bearing for the same reasons. The
5547    /// **test module** goes because the tests in it legitimately name the shapes
5548    /// they forbid — this module's own positive control is a literal
5549    /// `async fn acquire_jobs`, which would accuse the file of the thing it is
5550    /// proving it does not do. The **comments** go because this module's
5551    /// documentation explains the seam at length and has to name what does not
5552    /// exist in order to say why; a scan that forbade the explanation is a scan
5553    /// that gets the explanation deleted.
5554    fn production_half_of(source: &str) -> String {
5555        let production = source
5556            .split_once("\n#[cfg(test)]")
5557            .map_or(source, |(production, _)| production);
5558        production
5559            .lines()
5560            .filter(|line| !line.trim_start().starts_with("//"))
5561            .collect::<Vec<_>>()
5562            .join("\n")
5563    }
5564
5565    /// This file's own production half.
5566    fn this_file_above_its_tests_without_prose() -> String {
5567        production_half_of(include_str!("reconcile.rs"))
5568    }
5569
5570    /// The one normalisation both halves of the scan use.
5571    ///
5572    /// # This is a second copy of `crates/github/src/demand.rs`, deliberately
5573    ///
5574    /// `production_half_of`, this function, [`FORBIDDEN`] and
5575    /// `forbidden_shape_in` together duplicate `demand.rs:1530-1619`. Sharing
5576    /// them would mean putting them in `crates/testkit`, which `e1` does not
5577    /// own, so the copy was the only option available to this task.
5578    ///
5579    /// **It is worth consolidating later, and here is the specific hazard.**
5580    /// The last defect in `c4`'s copy was two spellings of "the same"
5581    /// normalisation drifting apart — the haystack lower-cased and the needle
5582    /// not — which made three of its seven assertions vacuously true from the
5583    /// day they were written. Two copies is the same hazard one level up. The
5584    /// mitigation inside *this* copy is that one function serves both the scan
5585    /// and its positive control, so a normaliser that stops matching fails the
5586    /// control loudly rather than passing the scan silently; what that cannot
5587    /// catch is this copy and `c4`'s diverging from each other.
5588    fn normalise_for_scan(text: &str) -> String {
5589        text.to_ascii_lowercase().replace(['_', ' '], "")
5590    }
5591
5592    /// The Actions-service call this design has no equivalent of, plus the
5593    /// shapes an implementer would invent in its place.
5594    ///
5595    /// Spelled in halves so that no needle ever appears whole in the text being
5596    /// scanned, and keyed to `fn`/`struct` so that the prose above may keep
5597    /// explaining why there is no reservation. `c4` records both trades at
5598    /// length; this list is its counterpart one layer up. Note that the
5599    /// allocation lock's own `fn acquire` is deliberately *not* matched: the
5600    /// needle is `acquire`-a-**job**, and a lock is not one.
5601    const FORBIDDEN: &[&str] = &[
5602        concat!("fn ", "acquire", "_job"),
5603        concat!("fn ", "claim", "_job"),
5604        concat!("fn ", "lease", "_job"),
5605        concat!("fn ", "reserve", "_job"),
5606        concat!("fn ", "ack", "nowledge"),
5607        concat!("struct ", "Job", "Lease"),
5608        concat!("struct ", "Job", "Claim"),
5609        concat!("struct ", "Job", "Reservation"),
5610    ];
5611
5612    fn forbidden_shape_in(source: &str) -> Option<&'static str> {
5613        let haystack = normalise_for_scan(source);
5614        FORBIDDEN
5615            .iter()
5616            .copied()
5617            .find(|forbidden| haystack.contains(&normalise_for_scan(forbidden)))
5618    }
5619
5620    /// `e1`'s Definition of Done: *"No reservation, claim, lease, or acquisition
5621    /// call exists in the crate; a test or review note records that this is
5622    /// deliberate rather than missing."*
5623    ///
5624    /// **Deliberate, not missing.** The scale-set model let a listener call
5625    /// `AcquireJobs` to claim an assignment before scaling; the REST path has no
5626    /// equivalent, so demand is advisory and two hosts serving the same labels
5627    /// can both start a runner for one queued run. Adding a local reservation
5628    /// table would not remove that — the other host cannot see it — it would
5629    /// only hide the surplus case from the tests that measure it. The three
5630    /// controls that actually bound it are host-scoped routing labels,
5631    /// `max_capacity`, and `host_capacity`, and the last two are enforced in
5632    /// this file.
5633    ///
5634    /// The scan is a tripwire on the obvious shape rather than a proof: a
5635    /// reservation reached through a trait method or a differently-named helper
5636    /// would walk past it. Review is the primary control, exactly as `c4` states
5637    /// for its own copy.
5638    ///
5639    /// # It scans the crate, because the bullet says "in the crate"
5640    ///
5641    /// It used to scan this file alone while quoting a crate-wide claim, which
5642    /// left `lifecycle.rs` — `e3`, the launcher, and by far the likeliest place
5643    /// for someone to "fix" the surplus-runner case with a local lease — covered
5644    /// by nothing. Reading another owner's file is not editing it, so ownership
5645    /// was never the obstacle.
5646    ///
5647    /// The walk below is `c4`'s, and it **recurses** for the reason `c4`
5648    /// records: a module directory (`src/reconcile/mod.rs`) arrives as an entry
5649    /// that does not end in `.rs`, so a flat filter drops it and takes every
5650    /// file underneath with it, leaving the scan passing over files it covers
5651    /// by nothing at all. The listed-versus-on-disk assertion is what stops
5652    /// `SOURCES` going stale the moment `e2` or `e3` adds a module.
5653    #[test]
5654    fn nothing_in_this_crate_reserves_or_claims_a_job() {
5655        const SOURCES: &[(&str, &str)] = &[
5656            ("dependency_caches.rs", include_str!("dependency_caches.rs")),
5657            ("lib.rs", include_str!("lib.rs")),
5658            ("lifecycle.rs", include_str!("lifecycle.rs")),
5659            (
5660                "lifecycle/windows_hyperv.rs",
5661                include_str!("lifecycle/windows_hyperv.rs"),
5662            ),
5663            ("oci.rs", include_str!("oci.rs")),
5664            ("package.rs", include_str!("package.rs")),
5665            ("reconcile.rs", include_str!("reconcile.rs")),
5666        ];
5667
5668        fn walk(directory: &std::path::Path, prefix: &str, found: &mut Vec<String>) {
5669            for entry in std::fs::read_dir(directory).expect("the crate's own src/ is readable") {
5670                let entry = entry.expect("a readable directory entry");
5671                let name = entry.file_name().to_string_lossy().into_owned();
5672                // `/`-joined, which is what `include_str!` takes on every
5673                // platform, so the two sides compare directly.
5674                let joined = if prefix.is_empty() {
5675                    name.clone()
5676                } else {
5677                    format!("{prefix}/{name}")
5678                };
5679                if entry.path().is_dir() {
5680                    walk(&entry.path(), &joined, found);
5681                } else if name.ends_with(".rs") {
5682                    found.push(joined);
5683                }
5684            }
5685        }
5686
5687        let mut listed: Vec<&str> = SOURCES.iter().map(|(name, _)| *name).collect();
5688        listed.sort_unstable();
5689        let mut on_disk = Vec::new();
5690        walk(
5691            std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/src")),
5692            "",
5693            &mut on_disk,
5694        );
5695        on_disk.sort_unstable();
5696        assert_eq!(
5697            listed, on_disk,
5698            "a source file was added or removed; this scan claims to cover the whole crate \
5699             and a stale list makes that claim false"
5700        );
5701
5702        for (name, source) in SOURCES {
5703            assert_eq!(
5704                forbidden_shape_in(&production_half_of(source)),
5705                None,
5706                "{name} names a forbidden shape: there is no `AcquireJobs` equivalent over \
5707                 REST, and a local lease coordinates this host with itself and with nothing \
5708                 else. If an owner decision restored one, that decision belongs in this \
5709                 module's documentation and in this test before it belongs in the code"
5710            );
5711        }
5712
5713        // The control: the scan can see a shape when there is one, through the
5714        // same matcher the loop above uses.
5715        assert!(
5716            forbidden_shape_in("async fn acquire_jobs(&self) -> Vec<Job> { todo!() }").is_some(),
5717            "the scan above proves nothing if the needles no longer match"
5718        );
5719    }
5720
5721    /// This module **applies** `b1`'s label predicate and implements none of it.
5722    ///
5723    /// The counterpart to `c4`'s scan over `crates/github/src/demand.rs`, and it
5724    /// checks the opposite thing, because the two modules sit on opposite sides
5725    /// of the same seam. `c4` builds a `RunsOn` per queued job and must name no
5726    /// `RoutingLabels`; this module holds the policy whose labels decide, so it
5727    /// must call `RoutingLabels::tally` and must not re-derive what that call
5728    /// answers.
5729    ///
5730    /// So the scan is in two halves:
5731    ///
5732    /// * **Present.** `DemandTally` has to appear, because [`demand_for`]
5733    ///   returns one. A production half that named it nowhere would mean the
5734    ///   filtering had been dropped and every queued job in a watched repository
5735    ///   was driving this policy toward `max_capacity` again.
5736    /// * **Absent.** The vocabulary of a *second* implementation. `b1` names the
5737    ///   three outcomes of matching one job; this module consumes the aggregate
5738    ///   and never a single job's verdict, so naming `RunsOnMatch` or
5739    ///   `UnresolvableRunsOn` here means a `match` on an outcome that
5740    ///   `RoutingLabels::tally` has already decided — which is how two copies of
5741    ///   a predicate start.
5742    ///
5743    /// Like the needles in `nothing_in_this_module_reserves_or_claims_a_job`,
5744    /// this is a tripwire on the obvious shape rather than a proof: a hand-rolled
5745    /// comparison of raw label strings that never names a `policy` type would
5746    /// walk past it. Stated rather than implied, for the same reason it is
5747    /// stated there.
5748    #[test]
5749    fn the_label_predicate_is_b1s_and_this_module_only_applies_it() {
5750        let production = this_file_above_its_tests_without_prose();
5751
5752        assert!(
5753            production.contains("DemandTally"),
5754            "the reconciliation loop must tally queued jobs against this policy's routing \
5755             labels. A production half that named `DemandTally` nowhere would mean the \
5756             label filtering had been removed, and a repository whose jobs target \
5757             `ubuntu-latest` would drive its policy toward `max_capacity` again"
5758        );
5759
5760        for second_implementation in ["RunsOnMatch", "UnresolvableRunsOn"] {
5761            assert!(
5762                !production.contains(second_implementation),
5763                "the reconciliation loop names `{second_implementation}`, which is the \
5764                 vocabulary of deciding one job's `runs-on` -- and `RoutingLabels::tally` \
5765                 has already decided it. This module applies the predicate and does not \
5766                 re-implement it; if an owner decision changed that, it belongs in this \
5767                 module's documentation and in this test before it belongs in the code"
5768            );
5769        }
5770    }
5771
5772    /// The demand this module clamps is the *matched* count, and a job this host
5773    /// cannot serve is not demand.
5774    ///
5775    /// The behaviour the whole reversal was for, asserted end to end through
5776    /// `demand_for` rather than through `b1`'s predicate in isolation: a policy
5777    /// carrying this host's labels, a reading holding some of its jobs and some
5778    /// of somebody else's, and the three counts kept apart.
5779    #[test]
5780    fn demand_is_the_queued_jobs_this_policy_can_actually_serve() {
5781        let policy = policy(1, "acme/app", 10);
5782        let reading = QueuedDemand::of(
5783            repo("acme/app"),
5784            [
5785                fixtures::queued_job(&[HOST_LABEL]),
5786                fixtures::queued_job(&[HOST_LABEL]),
5787                fixtures::queued_job(&["ubuntu-latest"]),
5788                fixtures::unresolvable_job(),
5789            ],
5790        );
5791
5792        let tally = demand_for(&policy, &reading);
5793
5794        assert_eq!(
5795            tally.demand(),
5796            2,
5797            "only the jobs whose required labels this policy carries are demand"
5798        );
5799        assert_eq!(
5800            tally.not_matched, 1,
5801            "a `ubuntu-latest` job is somebody else's work; counting it would start a \
5802             runner that idles until it times out"
5803        );
5804        assert_eq!(
5805            tally.unresolvable.len(),
5806            1,
5807            "an unresolvable `runs-on` is never demand and never discarded"
5808        );
5809    }
5810
5811    /// A repository target reads its own repository; an organization target
5812    /// reads the whole scope.
5813    #[test]
5814    fn an_organization_policy_tallies_every_repository_its_scope_covers() {
5815        let mut per_repository = BTreeMap::new();
5816        per_repository.insert(repo("acme/left"), fixtures::queued_jobs(&[HOST_LABEL], 3));
5817        per_repository.insert(repo("acme/right"), fixtures::queued_jobs(&[HOST_LABEL], 4));
5818        let reading = QueuedDemand::new(per_repository);
5819
5820        let repository_policy = policy(1, "acme/left", 10);
5821        assert_eq!(
5822            demand_for(&repository_policy, &reading).demand(),
5823            3,
5824            "a repository target reads its own repository's queue and not the aggregate"
5825        );
5826
5827        let org_policy = fixtures::policy()
5828            .id(PolicyId::from_u128(2))
5829            .organization("acme")
5830            .autoscale("home", 10)
5831            .active()
5832            .build();
5833        assert_eq!(
5834            demand_for(&org_policy, &reading).demand(),
5835            7,
5836            "an organization policy serves any repository in its scope, so its demand is \
5837             the whole aggregate's"
5838        );
5839    }
5840
5841    #[test]
5842    fn the_accepted_over_count_is_bounded_by_the_two_ceilings_and_nothing_else() {
5843        // The owner decision accepts that a repository whose jobs only target
5844        // `ubuntu-latest` still drives its policy toward `max_capacity`. What
5845        // stops that being unbounded is exactly what stops any other demand
5846        // being unbounded, which is asserted here rather than assumed.
5847        let host = host_with(2);
5848        let policy = policy(1, "acme/app", 5);
5849        let attempts: Vec<RunnerAttempt> = Vec::new();
5850        let mut allocator = HostAllocator::from_attempts(&host, &attempts);
5851
5852        let allocation = allocator.allocate(&policy, u32::MAX);
5853        assert_eq!(allocation.desired, 5, "max_capacity beats reported demand");
5854        assert_eq!(allocation.to_start, 2, "host_capacity beats max_capacity");
5855        assert_eq!(allocation.limiting_factor, LimitingFactor::HostCapacity);
5856    }
5857}