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 an *active* target on a bounded interval (default 60 s, hard floor
12//!   30 s per target) and *increases* the delay under a rate-limit signal,
13//!   never decreases it to catch up. A target with nothing happening is polled
14//!   at the shorter *idle* interval (default 10 s), because its polls are
15//!   conditional requests answered `304`, which GitHub does not charge against
16//!   the hourly ceiling — and because work usually arrives after idle. When
17//!   the account's remaining quota runs low, the schedule stretches.
18//! * [`RepositoryCache`] — the per-organization repository list, refreshed on
19//!   an interval materially slower than the demand poll. Re-listing an
20//!   organization at demand-poll frequency is what exhausts the shared budget
21//!   the paragraph above exists to protect.
22//! * [`Reconciler::reconcile`] — the allocation pass. It re-reads the attempt
23//!   set **under the host-wide allocation lock, once per runtime created**, so
24//!   two policies reconciling concurrently cannot both spend the same headroom.
25//! * [`LifecycleEvent`] — what `g2` and the local log sink see. Every field is
26//!   an identifier, a count, an enumerated state or a duration; nothing free
27//!   text, and nothing that came off the wire.
28//!
29//! # There is no acquisition step, and none may be added
30//!
31//! The scale-set model called `AcquireJobs` to reserve an assignment before
32//! scaling. The REST path has no equivalent (`01-current-architecture.md`, edge
33//! case 6), so demand is **advisory**. Two consequences are load-bearing here
34//! and neither is a defect:
35//!
36//! 1. **A surplus runner is an accepted outcome.** Another host serving the
37//!    same labels may take the job first; this host's runner then finds no work
38//!    and exits on its idle timeout, having cost one capacity slot and one cold
39//!    start. That terminal outcome is
40//!    [`AttemptOutcome::ExitedIdleWithoutWork`], is cleaned like any other, and
41//!    is counted apart from a failure — see [`ReconcileReport::idle_exits`].
42//! 2. **The same job is still `queued` on the next poll** while its runner
43//!    starts. Subtracting only unassigned supply in
44//!    [`HostAllocator::allocate`] is what stops that from starting a second
45//!    runner, and then a third. This module's only job in that arithmetic is to
46//!    hand the allocator the attempt set the host actually holds — which is why
47//!    [`RunnerLauncher`] supplies both the attempts and the launch, from one
48//!    supply point, for the reason `b1` gives at
49//!    [`HostAllocator::from_attempts`].
50//!
51//! `tests::nothing_in_this_module_reserves_or_claims_a_job` is a tripwire on the
52//! obvious shape of a reservation being added back.
53//!
54//! # Demand is measured in JOBS, filtered by this policy's routing labels
55//!
56//! `02-target-architecture.md` writes the formula as *"queued jobs whose
57//! `runs-on` matches this policy's routing labels"*, and that is now exactly
58//! what this module clamps. It was not always: an earlier owner decision priced
59//! the per-run job listing out and left this module clamping a count of
60//! **runs**, unfiltered. `crates/github/src/demand.rs` records that decision,
61//! why it was reversed, and what the reversal costs in requests.
62//!
63//! What the reversal means here is two changes to one line:
64//!
65//! * **A run of eight jobs is now eight units of demand, not one.** Under the
66//!   run count a matrix filled one runner per poll while the rest of the matrix
67//!   waited, so a host configured for ten concurrent runners served an
68//!   eight-job matrix nearly serially. That was the defect that forced the
69//!   decision back.
70//! * **A job this host cannot serve is no longer demand.** A repository whose
71//!   jobs target `ubuntu-latest`, or another host's `rm-<host>-…` label, used to
72//!   drive its policy toward `max_capacity` and start runners that idled until
73//!   they timed out. The gateway now returns each queued job's `runs-on`, so
74//!   `b1`'s predicate finally has its input.
75//!
76//! **The predicate is still `b1`'s and the input is still `c4`'s.** This module
77//! calls [`runner_manager_domain::policy::RoutingLabels`]'s `tally` and
78//! implements no label comparison of its own;
79//! `tests::the_label_predicate_is_b1s_and_this_module_only_applies_it` scans
80//! this file's own source and fails if a second implementation grows here, which
81//! is the same tripwire `c4` carries one layer down.
82//!
83//! # The filtering happens here rather than in the gateway, on purpose
84//!
85//! One target can be watched by more than one policy, each with its own routing
86//! labels, and [`Reconciler`]'s `poll_targets` deliberately polls a target **once**
87//! for all of them. A gateway that filtered would have to be told whose labels to
88//! filter by, which would make the poll per-policy and multiply its request cost
89//! by the number of policies sharing the target — the budget model prices a
90//! target, not a policy. So the gateway returns the jobs and each policy tallies
91//! them against its own labels.
92//!
93//! # What is still approximate
94//!
95//! The surplus-runner path above is narrowed by this change and not closed. A
96//! `runs-on: ${{ matrix.runner }}` cannot be resolved without evaluating the
97//! workflow, so `b1` reports it as unresolvable: never counted as demand, never
98//! silently dropped, and surfaced through
99//! [`LifecycleEvent::DemandObserved::unresolvable`] so that an operator can see
100//! a workflow this host will never serve sitting in the queue. And demand
101//! remains advisory — another host may still take a job this one started a
102//! runner for — which is what the two ceilings bound.
103//!
104//! # What is testable without a network, a filesystem, or a process
105//!
106//! All of it. [`DemandSource`], [`RunnerLauncher`], [`AllocationLock`],
107//! [`RepositoryDirectory`], [`Jitter`] and [`EventSink`] are ports;
108//! [`GatewayDemand`], [`FileAllocationLock`], [`RandomJitter`] and
109//! [`TracingEvents`] are the production adapters, and every one of them is a
110//! thin shell over a decision made in this file.
111
112use std::collections::{BTreeMap, BTreeSet};
113use std::fmt;
114use std::num::NonZeroU16;
115use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
116use std::sync::{Arc, Mutex};
117use std::time::Duration;
118
119use runner_manager_domain::attempt::{
120    AttemptOutcome, AttemptState, FailureReason, IsolationProviderFailure, RunnerAttempt,
121};
122use runner_manager_domain::capacity::{Allocation, HostAllocator, LimitingFactor};
123use runner_manager_domain::memory::{MemoryFigures, MemoryVerdict};
124use runner_manager_domain::model::{
125    AttemptId, Clock, Host, IdlePollInterval, Org, OwnerRepo, PolicyId, RefreshInterval,
126    ScaleTarget, Timestamp,
127};
128use runner_manager_domain::policy::{DemandTally, ScalePolicy};
129use runner_manager_github::demand::{DemandGateway, QueuedDemand, demand_requests_per_poll};
130use runner_manager_github::rest::{
131    ActivityScope, CancelToken, InventoryError, RateLimitKind, RefreshState,
132};
133use runner_manager_github::traffic::{PrimaryPressure, RateLimitSnapshot};
134
135// ---------------------------------------------------------------------------
136// Constants
137// ---------------------------------------------------------------------------
138
139/// How much slower than the demand poll the per-organization repository list is
140/// refreshed.
141///
142/// There is no organization-wide workflow-runs endpoint, so an organization
143/// target costs one demand request **per repository the App is installed on**
144/// (`crates/github/src/demand.rs`). Discovering that repository list costs
145/// requests of its own, and it is the one input to a demand poll that changes on
146/// a human timescale: repositories are added to an installation by hand, not by
147/// a workflow starting.
148///
149/// Thirty polls is 30 minutes at the 60-second default and 15 at the 30-second
150/// floor — slow enough that the list is a rounding error against the demand
151/// requests it scopes, and fast enough that a repository added to the
152/// installation starts being served within one coffee break rather than at the
153/// next restart.
154pub const REPOSITORY_LIST_REFRESH_MULTIPLE: u32 = 30;
155
156/// The longest the *unjittered* offline back-off may grow to.
157///
158/// A back-off is a safety mechanism, and an unclamped one is an outage with
159/// extra steps. Fifteen minutes matches
160/// [`runner_manager_github::rest::MAX_RATE_LIMIT_BACKOFF`], which is the other
161/// place in this product where a delay is allowed to grow, and it is far inside
162/// the 24-hour bound at which GitHub cancels the queued jobs this loop exists to
163/// serve.
164pub const MAX_OFFLINE_BACKOFF: Duration = Duration::from_secs(15 * 60);
165
166/// The most the offline back-off is doubled, before the cap applies.
167///
168/// At the 60-second default this reaches [`MAX_OFFLINE_BACKOFF`] on the sixth
169/// consecutive failure, which is roughly half an hour of outage. Past that the
170/// cap holds it flat.
171const MAX_BACKOFF_DOUBLINGS: u32 = 5;
172
173/// How much of the computed back-off is jitter.
174///
175/// Jitter is **added** rather than subtracted, so a back-off never comes out
176/// shorter than the delay it was computed from. Subtractive jitter would let the
177/// first offline poll retry sooner than the nominal interval, which is the
178/// opposite of backing off; it is spelled out because "add jitter" reads as
179/// symmetric and is not.
180const JITTER_RATIO: f64 = 0.5;
181
182/// How much of a rate-limit wait is jitter: added, never subtracted.
183///
184/// Every target loop on a host shares one rate-limit quiet period, so without
185/// it they all wake at the same instant the period ends, and a burst is the
186/// shape most likely to meet the secondary limit again. A tenth spreads them
187/// over a few seconds to a minute and a half without overshooting a
188/// 15-minute wait by much.
189const RATE_LIMIT_JITTER_RATIO: f64 = 0.1;
190
191/// The most demand requests a minute one target's idle polling may cost.
192///
193/// A third of GitHub's 900-a-minute secondary limit. A target of one or a few
194/// repositories never reaches it at the 5-second floor, but an organization of
195/// a hundred repositories costs about four hundred requests a pass, and at a
196/// 10-second idle interval that alone would exceed the whole account's
197/// secondary limit. Its idle interval stretches instead.
198pub const MAX_IDLE_REQUESTS_PER_MINUTE_PER_TARGET: u32 = 300;
199
200/// GitHub cancels a queued job after this long.
201///
202/// `01-current-architecture.md` records the measurement; `03-control-flows.md`
203/// flow 3.3 requires that the offline state **states** it, because an agent
204/// offline for longer than this has lost queued work and the operator cannot
205/// infer that from "offline". [`OfflineState`] is where it is said.
206pub const GITHUB_CANCELS_QUEUED_JOBS_AFTER: Duration = Duration::from_secs(24 * 60 * 60);
207
208/// How long [`FileAllocationLock`] waits for the host-wide allocation lock
209/// before reporting contention.
210///
211/// Contention here is expected rather than exceptional — it is two of this
212/// host's own policies creating runtimes at the same moment — and each hold
213/// lasts only as long as one runtime creation. Waiting a few seconds turns the
214/// common case into a short pause instead of a skipped runner.
215///
216/// # How many of these a poll actually costs
217///
218/// One per runtime created, none for a policy that is granted nothing, and at
219/// most one further hold per policy — the case where the pre-check proposed a
220/// grant and the under-lock re-read found the host had filled up underneath it,
221/// so that hold creates nothing and ends the loop. `(3..=5)` in
222/// `two_policies_reconciling_concurrently_never_exceed_host_capacity` is that
223/// bound with two policies and three runtimes; the deterministic single-policy
224/// case is pinned at exactly one per runtime.
225///
226/// That is worth stating because it did not used to be true and the
227/// difference only shows up here: the budget was checked *after* the lock had
228/// been taken and the attempt set re-read, so a policy granted N runners took
229/// N+1 holds, and `start_runners` ran for every readable autoscale policy
230/// including the zero-demand ones — so an idle host with P policies took P
231/// host-wide locks per poll for nothing. Free under
232/// [`InProcessAllocationLock`]; under [`FileAllocationLock`] each one is a
233/// `spawn_blocking` plus a filesystem lock, with this wait behind it.
234///
235/// [`Reconciler::start_runners`] now pre-checks lock-free and stops as soon as
236/// the budget is spent. The under-lock re-read still decides.
237pub const ALLOCATION_LOCK_WAIT: Duration = Duration::from_secs(5);
238
239// ---------------------------------------------------------------------------
240// What one demand poll produced
241// ---------------------------------------------------------------------------
242
243/// One target's demand poll, as a value this module can decide from.
244///
245/// The failure half is `c3`'s [`RefreshState`] rather than an
246/// [`InventoryError`], for the reason `c3` gives: `InventoryError` owns a
247/// `reqwest::Error` and a `serde_json::Error`, so it is neither `Clone` nor
248/// `PartialEq` and cannot be stored, compared, or rendered. Summarising at the
249/// gateway boundary — exactly once, in [`GatewayDemand`] — is what lets the
250/// whole schedule below be a pure function of values a test can construct.
251///
252/// [`RefreshState::Ready`] never appears in [`PollOutcome::Failed`]:
253/// [`RefreshState::from_error`] cannot produce it, and a demand poll returns a
254/// [`QueuedDemand`] rather than the runner inventory that variant carries.
255#[derive(Debug, Clone, PartialEq, Eq)]
256pub enum PollOutcome {
257    /// GitHub answered. The count may still be a floor — see
258    /// [`QueuedDemand::is_complete`].
259    Ready(QueuedDemand),
260    /// GitHub did not answer, or answered something this loop must slow down
261    /// for.
262    Failed(RefreshState),
263}
264
265impl PollOutcome {
266    /// The demand reading, when there is one.
267    #[must_use]
268    pub const fn reading(&self) -> Option<&QueuedDemand> {
269        match self {
270            Self::Ready(demand) => Some(demand),
271            Self::Failed(_) => None,
272        }
273    }
274
275    /// The failure, when there is one.
276    #[must_use]
277    pub const fn failure(&self) -> Option<&RefreshState> {
278        match self {
279            Self::Failed(state) => Some(state),
280            Self::Ready(_) => None,
281        }
282    }
283
284    /// Whether GitHub could not be reached at all, as opposed to answering
285    /// something unwelcome.
286    ///
287    /// The whole of flow 3.3 turns on this distinction: an outage retains
288    /// running runners and backs off, while a rejection is a configuration
289    /// problem that waiting does not fix.
290    #[must_use]
291    pub fn is_offline(&self) -> bool {
292        matches!(self, Self::Failed(RefreshState::Offline))
293    }
294}
295
296/// Where this loop gets its demand from.
297///
298/// A port rather than a direct [`DemandGateway`] dependency, because the two
299/// failures this loop must handle differently — unreachable and rate-limited —
300/// are distinguished by [`RefreshState`], and a test that wants to drive the
301/// offline path should not have to manufacture a `reqwest::Error` to do it.
302/// [`GatewayDemand`] is the one adapter that talks to `c4`.
303#[async_trait::async_trait]
304pub trait DemandSource: fmt::Debug + Send + Sync {
305    /// Queued runs across `scope`, or why there are none to report.
306    async fn poll(&self, scope: &ActivityScope) -> PollOutcome;
307}
308
309/// [`DemandSource`] over `c4`'s [`DemandGateway`].
310///
311/// Holds the [`CancelToken`] so that a shutting-down daemon can withdraw a poll
312/// that is already blocked on a socket; `f3` keeps a clone and cancels it.
313#[derive(Debug)]
314pub struct GatewayDemand<G> {
315    gateway: G,
316    cancel: CancelToken,
317}
318
319impl<G: DemandGateway> GatewayDemand<G> {
320    #[must_use]
321    pub const fn new(gateway: G, cancel: CancelToken) -> Self {
322        Self { gateway, cancel }
323    }
324
325    #[must_use]
326    pub const fn gateway(&self) -> &G {
327        &self.gateway
328    }
329}
330
331#[async_trait::async_trait]
332impl<G: DemandGateway + 'static> DemandSource for GatewayDemand<G> {
333    async fn poll(&self, scope: &ActivityScope) -> PollOutcome {
334        match self.gateway.queued_demand(scope, &self.cancel).await {
335            Ok(demand) => PollOutcome::Ready(demand),
336            // The one place an `InventoryError` is summarised. `c3` owns the
337            // mapping — including transport-to-`Offline`, which is what flow
338            // 3.3 branches on — so this loop never re-decides it.
339            Err(error) => PollOutcome::Failed(RefreshState::from_error(&error)),
340        }
341    }
342}
343
344// ---------------------------------------------------------------------------
345// The repository list, cached
346// ---------------------------------------------------------------------------
347
348/// Which repositories an organization installation reaches.
349///
350/// `f1` already holds this, from
351/// [`runner_manager_github::AuthenticatedClient::discover_installations`]. It is
352/// a port here so that [`RepositoryCache`] can be tested for the property that
353/// matters — how *often* it asks — without a network.
354#[async_trait::async_trait]
355pub trait RepositoryDirectory: fmt::Debug + Send + Sync {
356    /// The repositories this credential reaches in `org`.
357    ///
358    /// # Errors
359    /// Anything the underlying gateway reports.
360    async fn repositories(&self, org: &Org) -> Result<Vec<OwnerRepo>, InventoryError>;
361}
362
363#[derive(Debug, Clone)]
364struct CachedRepositories {
365    repositories: Vec<OwnerRepo>,
366    fetched_at: Timestamp,
367}
368
369/// The per-organization repository list, refreshed far more slowly than demand.
370///
371/// # Why this is not just "call the directory each poll"
372///
373/// An organization demand poll already costs one request per repository. Adding
374/// the installation listing to every poll makes the *scoping* of a poll cost
375/// requests on the same schedule as the poll itself, which is how a
376/// ten-repository organization at the 30-second floor stops fitting inside the
377/// half-of-5,000 allowance `f2` admits targets against. The repository list is
378/// also the one input that changes on a human timescale, so refreshing it
379/// [`REPOSITORY_LIST_REFRESH_MULTIPLE`] times more slowly costs nothing real.
380///
381/// # A repository target never consults the directory at all
382///
383/// Its scope is itself. That is not an optimisation; asking an installation
384/// listing which repositories a single named repository covers would be asking a
385/// question whose answer is already in the target.
386#[derive(Debug)]
387pub struct RepositoryCache {
388    directory: Arc<dyn RepositoryDirectory>,
389    clock: Arc<dyn Clock>,
390    ttl: Duration,
391    entries: Mutex<BTreeMap<Org, CachedRepositories>>,
392    lookups: AtomicU64,
393}
394
395impl RepositoryCache {
396    /// Build a cache whose refresh interval is `poll` slowed by
397    /// [`REPOSITORY_LIST_REFRESH_MULTIPLE`].
398    #[must_use]
399    pub fn new(
400        directory: Arc<dyn RepositoryDirectory>,
401        clock: Arc<dyn Clock>,
402        poll: RefreshInterval,
403    ) -> Self {
404        let ttl = Duration::from_secs(u64::from(poll.as_secs()))
405            .saturating_mul(REPOSITORY_LIST_REFRESH_MULTIPLE);
406        Self {
407            directory,
408            clock,
409            ttl,
410            entries: Mutex::new(BTreeMap::new()),
411            lookups: AtomicU64::new(0),
412        }
413    }
414
415    /// How long a cached repository list is reused for.
416    #[must_use]
417    pub const fn ttl(&self) -> Duration {
418        self.ttl
419    }
420
421    /// How many times the underlying directory was actually asked.
422    ///
423    /// Measured rather than assumed, for the reason `c4` measures its own
424    /// request count: a budget nothing counts is a table in a document.
425    #[must_use]
426    pub fn lookups(&self) -> u64 {
427        self.lookups.load(Ordering::SeqCst)
428    }
429
430    /// The scope one demand poll of `target` covers.
431    ///
432    /// # Errors
433    /// Whatever the directory reported, for an organization target whose list is
434    /// stale or absent. A repository target cannot fail.
435    pub async fn scope_for(&self, target: &ScaleTarget) -> Result<ActivityScope, InventoryError> {
436        match target {
437            ScaleTarget::Repository(repository) => {
438                Ok(ActivityScope::repository(repository.clone()))
439            }
440            ScaleTarget::Organization(org) => {
441                let repositories = self.repositories_of(org).await?;
442                Ok(ActivityScope::organization(org.clone(), repositories))
443            }
444        }
445    }
446
447    async fn repositories_of(&self, org: &Org) -> Result<Vec<OwnerRepo>, InventoryError> {
448        let now = self.clock.now();
449        if let Some(fresh) = self.fresh_entry(org, now) {
450            return Ok(fresh);
451        }
452
453        // The directory call is deliberately made with no lock held. Two
454        // concurrent misses can therefore both ask, which costs one extra
455        // listing on the poll that follows a restart; holding a `std::sync`
456        // mutex across an `await` would cost a blocked executor thread and, on
457        // a current-thread runtime, a deadlock. The cheaper mistake is the one
458        // that spends a request.
459        let repositories = self.directory.repositories(org).await?;
460        self.lookups.fetch_add(1, Ordering::SeqCst);
461        self.store(org.clone(), repositories.clone(), now);
462        Ok(repositories)
463    }
464
465    fn fresh_entry(&self, org: &Org, now: Timestamp) -> Option<Vec<OwnerRepo>> {
466        let entries = self.entries.lock().ok()?;
467        let entry = entries.get(org)?;
468        let age = now.signed_duration_since(entry.fetched_at).to_std().ok()?;
469        (age < self.ttl).then(|| entry.repositories.clone())
470    }
471
472    fn store(&self, org: Org, repositories: Vec<OwnerRepo>, fetched_at: Timestamp) {
473        if let Ok(mut entries) = self.entries.lock() {
474            entries.insert(
475                org,
476                CachedRepositories {
477                    repositories,
478                    fetched_at,
479                },
480            );
481        }
482    }
483}
484
485// ---------------------------------------------------------------------------
486// Jitter
487// ---------------------------------------------------------------------------
488
489/// The randomness in the offline back-off, as a port.
490///
491/// Flow 3.3 requires jittered back-off, and a jittered delay is by construction
492/// not reproducible — so the source of the randomness is a port, and every test
493/// below asserts the *bounds* of the delay against a fixed fraction rather than
494/// asserting a number it could only have got by running the generator.
495pub trait Jitter: fmt::Debug + Send + Sync {
496    /// A fraction in `[0.0, 1.0)`. Values outside that range are clamped by the
497    /// caller, so an implementation cannot lengthen a back-off without bound.
498    fn fraction(&self) -> f64;
499}
500
501/// The production source.
502#[derive(Debug, Clone, Copy, Default)]
503pub struct RandomJitter;
504
505impl Jitter for RandomJitter {
506    fn fraction(&self) -> f64 {
507        rand::random::<f64>()
508    }
509}
510
511/// A fixed fraction, for tests and for the acceptance suite.
512#[derive(Debug, Clone, Copy)]
513pub struct FixedJitter(pub f64);
514
515impl Jitter for FixedJitter {
516    fn fraction(&self) -> f64 {
517        self.0
518    }
519}
520
521/// No jitter at all: the back-off is exactly what the schedule computed.
522#[derive(Debug, Clone, Copy, Default)]
523pub struct NoJitter;
524
525impl Jitter for NoJitter {
526    fn fraction(&self) -> f64 {
527        0.0
528    }
529}
530
531// ---------------------------------------------------------------------------
532// The schedule
533// ---------------------------------------------------------------------------
534
535/// Why the next poll is when it is.
536///
537/// Reported rather than inferred, because
538/// `04-subsystem-contracts.md` requires that rate limiting be *"displayed, never
539/// hidden"* — and a delay that grew for a reason the caller cannot name is
540/// hidden however visible the number is.
541#[derive(Debug, Clone, Copy, PartialEq, Eq)]
542pub enum PollPace {
543    /// The configured (active) interval. Nothing is throttling this loop.
544    Nominal,
545    /// The idle interval: nothing is happening on this target, so its polls
546    /// are `304`s and cost nothing against the hourly quota. Not a slowdown.
547    Idle,
548    /// The account's remaining hourly quota is low, so this loop waits a
549    /// multiple of its active interval. GitHub's quota is per user, shared by
550    /// every host signed in as that user, so this can be another machine's
551    /// doing.
552    Stretched { pressure: PrimaryPressure },
553    /// GitHub's rate limit is exhausted. Resolves by waiting.
554    RateLimited { kind: RateLimitKind },
555    /// GitHub's temporary authentication lockout. The credential is fine.
556    LockedOut,
557    /// GitHub could not be reached. `consecutive` counts the unbroken run of
558    /// failures the back-off was computed from.
559    Offline { consecutive: u32 },
560    /// GitHub answered something no amount of waiting fixes — a rejected
561    /// credential, a permissions refusal, or an error status. The loop keeps
562    /// polling at its nominal interval so that a fix is noticed, and says that
563    /// it is blocked rather than pretending the poll succeeded.
564    Blocked,
565}
566
567impl PollPace {
568    /// Whether this pace is a slowdown the operator should be told about.
569    #[must_use]
570    pub const fn is_throttled(&self) -> bool {
571        !matches!(self, Self::Nominal | Self::Idle)
572    }
573
574    /// A fixed, credential-free name for the log sink and for `g2`.
575    #[must_use]
576    pub const fn as_str(&self) -> &'static str {
577        match self {
578            Self::Nominal => "nominal",
579            Self::Idle => "idle",
580            Self::Stretched {
581                pressure: PrimaryPressure::Normal,
582            } => "nominal",
583            Self::Stretched {
584                pressure: PrimaryPressure::Low,
585            } => "stretched_low_budget",
586            Self::Stretched {
587                pressure: PrimaryPressure::Critical,
588            } => "stretched_critical_budget",
589            Self::RateLimited {
590                kind: RateLimitKind::Primary,
591            } => "rate_limited_primary",
592            Self::RateLimited {
593                kind: RateLimitKind::Secondary,
594            } => "rate_limited_secondary",
595            Self::LockedOut => "locked_out",
596            Self::Offline { .. } => "offline",
597            Self::Blocked => "blocked",
598        }
599    }
600}
601
602impl fmt::Display for PollPace {
603    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
604        f.write_str(self.as_str())
605    }
606}
607
608/// When to poll next, and why then.
609#[derive(Debug, Clone, Copy, PartialEq, Eq)]
610pub struct NextPoll {
611    pub delay: Duration,
612    pub pace: PollPace,
613}
614
615/// The bounded, budget-aware poll interval.
616///
617/// # The floor is a rate-budget constraint, not a preference
618///
619/// [`RefreshInterval`] refuses anything under 30 seconds at construction, and
620/// every delay this type produces is at least that — including the ones it
621/// computes from a remote header. A rate limit may only ever make this loop
622/// *slower*.
623///
624/// # `retry_delay` is an absolute floor, not an addend
625///
626/// `c3` documents [`RefreshState::retry_delay`] as *"the earliest time a retry
627/// may occur"*: the scheduling rule is `next_attempt_at = now + retry_delay`,
628/// and **not** the ordinary interval plus it. Adding the two compounds on every
629/// successive retry — each new answer carries the remaining window, so an
630/// addend ratchets outward — and the symptom is a dashboard that stays dark
631/// long after GitHub said it could come back, which reads as a hang rather than
632/// as a rate limit. So the two are combined with `max`, which is what makes the
633/// floor a floor.
634#[derive(Debug, Clone)]
635pub struct PollSchedule {
636    interval: RefreshInterval,
637    /// The idle interval. Equal to the active one until [`Self::with_idle`]
638    /// says otherwise, so a caller that never heard of it keeps its schedule.
639    idle: Duration,
640    consecutive_offline: u32,
641    offline_since: Option<Timestamp>,
642}
643
644/// What a successful pass found, beyond "GitHub answered".
645#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
646pub struct PollConditions {
647    /// Nothing is happening: no live runner, no run whose listings changed.
648    /// See `QueuedDemand::is_quiet`.
649    pub quiet: bool,
650    /// The account's hourly quota as GitHub last reported it.
651    pub quota: Option<RateLimitSnapshot>,
652    /// What one poll of this target costs, in demand requests. Stretches the
653    /// idle interval of a target too large to poll that often; see
654    /// [`MAX_IDLE_REQUESTS_PER_MINUTE_PER_TARGET`].
655    pub requests_per_pass: u32,
656}
657
658impl PollSchedule {
659    #[must_use]
660    pub const fn new(interval: RefreshInterval) -> Self {
661        Self {
662            interval,
663            idle: Duration::from_secs(interval.as_secs() as u64),
664            consecutive_offline: 0,
665            offline_since: None,
666        }
667    }
668
669    /// Poll a quiet target every `idle` instead of every active interval.
670    ///
671    /// Never longer than the active interval: an idle interval above it would
672    /// make a quiet target *slower* to notice work than a busy one.
673    #[must_use]
674    pub fn with_idle(mut self, idle: Duration) -> Self {
675        self.set_intervals(self.interval, idle);
676        self
677    }
678
679    /// Change both intervals while running.
680    pub fn set_intervals(&mut self, interval: RefreshInterval, idle: Duration) {
681        self.interval = interval;
682        self.idle = idle.min(self.nominal()).max(Self::idle_floor());
683    }
684
685    #[must_use]
686    pub const fn interval(&self) -> RefreshInterval {
687        self.interval
688    }
689
690    /// The interval a quiet target is polled at.
691    #[must_use]
692    pub const fn idle(&self) -> Duration {
693        self.idle
694    }
695
696    /// The shortest delay this schedule ever produces: the idle interval's
697    /// floor. See `IdlePollInterval` for why it is lower than [`Self::floor`].
698    #[must_use]
699    pub const fn idle_floor() -> Duration {
700        Duration::from_secs(IdlePollInterval::MIN_SECS as u64)
701    }
702
703    /// The nominal interval as a [`Duration`].
704    #[must_use]
705    pub const fn nominal(&self) -> Duration {
706        Duration::from_secs(self.interval.as_secs() as u64)
707    }
708
709    /// The unbroken run of offline polls this schedule has seen.
710    #[must_use]
711    pub const fn consecutive_offline(&self) -> u32 {
712        self.consecutive_offline
713    }
714
715    /// How long GitHub has been unreachable, or `None` when it is not.
716    ///
717    /// Measured from the first poll of the current run rather than inferred
718    /// from [`Self::consecutive_offline`] times the interval. The two diverge
719    /// as soon as the back-off starts doubling, and this is the number the
720    /// 24-hour queue-cancellation warning is compared against — an estimate
721    /// would make that warning fire early or late, and it is the one thing the
722    /// offline state exists to say.
723    #[must_use]
724    pub fn offline_for(&self, now: Timestamp) -> Option<Duration> {
725        let since = self.offline_since?;
726        now.signed_duration_since(since).to_std().ok()
727    }
728
729    /// The hard floor no computed delay may go below.
730    #[must_use]
731    pub const fn floor() -> Duration {
732        Duration::from_secs(RefreshInterval::MIN_SECS as u64)
733    }
734
735    /// Decide when to poll next, given how this pass ended.
736    ///
737    /// `failure` is the most severe failure across the targets polled this pass,
738    /// or `None` when every target answered. A pass that answered resets the
739    /// offline run, which is the whole of "recovery needs no bookkeeping":
740    /// demand is recomputed from the current queued-run set on every poll, so
741    /// there is nothing else to unwind.
742    pub fn next_poll(
743        &mut self,
744        failure: Option<&RefreshState>,
745        now: Timestamp,
746        jitter: &dyn Jitter,
747    ) -> NextPoll {
748        self.next_poll_with(failure, PollConditions::default(), now, jitter)
749    }
750
751    /// [`Self::next_poll`], with what a successful pass found.
752    ///
753    /// # On success, three paces, in this order
754    ///
755    /// 1. **Stretched**, when the account's remaining quota is under
756    ///    `LOW_PRIMARY_REMAINING_PERCENT`: the active interval times
757    ///    [`PrimaryPressure::stretch_factor`] (2, or 4 under
758    ///    `CRITICAL_PRIMARY_REMAINING_PERCENT`), but never past the quota's
759    ///    reset — waiting longer than that buys nothing, because the bucket is
760    ///    full again. This wins over idle: a `304` is free, but the poll that
761    ///    finds new work is not, and every host on the account draws from the
762    ///    same bucket.
763    /// 2. **Idle**, when the pass was quiet.
764    /// 3. **Nominal**, the active interval, otherwise.
765    pub fn next_poll_with(
766        &mut self,
767        failure: Option<&RefreshState>,
768        conditions: PollConditions,
769        now: Timestamp,
770        jitter: &dyn Jitter,
771    ) -> NextPoll {
772        let nominal = self.nominal();
773
774        let next = match failure {
775            None => {
776                self.recovered();
777                let pressure = conditions
778                    .quota
779                    .map_or(PrimaryPressure::Normal, |quota| quota.pressure(now));
780                match (pressure, conditions.quota) {
781                    (PrimaryPressure::Low | PrimaryPressure::Critical, Some(quota)) => NextPoll {
782                        delay: nominal
783                            .saturating_mul(pressure.stretch_factor())
784                            .min(quota.reset_in(now).max(nominal)),
785                        pace: PollPace::Stretched { pressure },
786                    },
787                    // An idle interval no shorter than the active one is no
788                    // idle interval at all, and naming it would be noise.
789                    _ if conditions.quiet && self.idle < nominal => NextPoll {
790                        delay: self.idle.max(idle_floor_for(conditions.requests_per_pass)),
791                        pace: PollPace::Idle,
792                    },
793                    _ => NextPoll {
794                        delay: nominal,
795                        pace: PollPace::Nominal,
796                    },
797                }
798            }
799            Some(RefreshState::Offline) => {
800                self.consecutive_offline = self.consecutive_offline.saturating_add(1);
801                // The instant the *run* began, not the instant of this poll.
802                self.offline_since.get_or_insert(now);
803                NextPoll {
804                    delay: self.offline_delay(nominal, jitter),
805                    pace: PollPace::Offline {
806                        consecutive: self.consecutive_offline,
807                    },
808                }
809            }
810            Some(state @ RefreshState::RateLimited(limit)) => {
811                self.recovered();
812                // `max`, never `+`. See the type documentation. The jitter is
813                // added to the result, never to the floor GitHub named.
814                let wait = retry_floor(state, now).max(nominal);
815                NextPoll {
816                    delay: wait.saturating_add(
817                        wait.mul_f64(RATE_LIMIT_JITTER_RATIO * jitter.fraction().clamp(0.0, 1.0)),
818                    ),
819                    pace: PollPace::RateLimited { kind: limit.kind },
820                }
821            }
822            Some(state @ RefreshState::LockedOut { .. }) => {
823                self.recovered();
824                NextPoll {
825                    delay: retry_floor(state, now).max(nominal),
826                    pace: PollPace::LockedOut,
827                }
828            }
829            // Unauthorized, Forbidden, Failed, Cancelled. `retry_delay` is
830            // `None` for all of them, and deliberately: no wait fixes a revoked
831            // credential or a missing grant. Polling stops being useful but
832            // does not stop, because the poll is also how a re-authentication
833            // is noticed.
834            Some(_) => {
835                // GitHub answered, so it is reachable: whatever is wrong, it is
836                // not an outage, and an outage run that was open must close.
837                self.recovered();
838                NextPoll {
839                    delay: nominal,
840                    pace: PollPace::Blocked,
841                }
842            }
843        };
844
845        debug_assert!(
846            next.delay >= Self::floor() || next.pace == PollPace::Idle,
847            "the 30-second floor is a rate-budget constraint and no charged branch may go below it"
848        );
849        debug_assert!(
850            next.delay >= Self::idle_floor(),
851            "the idle floor is a secondary-limit constraint and no branch may go below it"
852        );
853        next
854    }
855
856    /// GitHub answered something. Whatever it was, the outage run is over.
857    fn recovered(&mut self) {
858        self.consecutive_offline = 0;
859        self.offline_since = None;
860    }
861
862    fn offline_delay(&self, nominal: Duration, jitter: &dyn Jitter) -> Duration {
863        let doublings = self
864            .consecutive_offline
865            .saturating_sub(1)
866            .min(MAX_BACKOFF_DOUBLINGS);
867        let grown = nominal.saturating_mul(1_u32 << doublings);
868        let capped = grown.min(MAX_OFFLINE_BACKOFF);
869        // Additive, never subtractive: see `JITTER_RATIO`. The result may exceed
870        // `MAX_OFFLINE_BACKOFF` by up to the jitter ratio, which is the price of
871        // keeping a fleet of agents from retrying in lockstep at the plateau —
872        // a cap applied *after* jitter would collapse every agent onto the same
873        // instant precisely when the outage is longest.
874        let spread = capped.mul_f64(JITTER_RATIO * jitter.fraction().clamp(0.0, 1.0));
875        capped.saturating_add(spread).max(Self::floor())
876    }
877}
878
879/// Whether a pass found nothing happening, so the next one may come at the
880/// idle interval.
881///
882/// All three are needed. Every target answered, and quietly
883/// (`QueuedDemand::is_quiet`); this pass started no runner; and no policy
884/// here holds a live one. A live runner means a job is running or about to,
885/// and its follow-on work (`needs:`, the next matrix wave) is exactly what an
886/// active target is polled for. A pass that polled nothing is not quiet: there
887/// is nothing to be quiet about, and the active interval is what it had
888/// before.
889fn is_quiet(readings: &BTreeMap<ScaleTarget, PollOutcome>, report: &ReconcileReport) -> bool {
890    !readings.is_empty()
891        && readings
892            .values()
893            .all(|outcome| outcome.reading().is_some_and(QueuedDemand::is_quiet))
894        && report.started == 0
895        // A missing allocation after a journal failure is unknown activity,
896        // not evidence that the host is idle.
897        && report.host_capacity_unreadable == 0
898        && report.attempts_unreadable == 0
899        // Demand this host matched and did not serve is not quiet either: a
900        // launch that keeps failing (a refused JIT registration) would
901        // otherwise be retried, and charged, every few seconds.
902        && report
903            .allocations
904            .iter()
905            .all(|allocation| allocation.active_owned == 0 && allocation.demand == 0)
906}
907
908/// The shortest idle delay a poll of `requests_per_pass` requests may have.
909fn idle_floor_for(requests_per_pass: u32) -> Duration {
910    Duration::from_secs(
911        u64::from(requests_per_pass) * 60 / u64::from(MAX_IDLE_REQUESTS_PER_MINUTE_PER_TARGET),
912    )
913}
914
915/// `c3`'s retry floor, with the one fallback this loop needs.
916///
917/// [`RefreshState::retry_delay`] answers `None` for the states no wait fixes,
918/// and those never reach here — the caller matches them into
919/// [`PollPace::Blocked`] first. The fallback exists so that a future
920/// `RefreshState` variant added to the two arms above cannot silently schedule a
921/// zero-second retry against an endpoint that asked for quiet.
922fn retry_floor(state: &RefreshState, now: Timestamp) -> Duration {
923    state.retry_delay(now).unwrap_or(PollSchedule::floor())
924}
925
926// ---------------------------------------------------------------------------
927// Offline
928// ---------------------------------------------------------------------------
929
930/// What an operator is told while GitHub is unreachable.
931///
932/// Flow 3.3 requires four things of an outage — start no new runner, retain
933/// existing runner processes, report `offline`, back off with jitter — and one
934/// thing of the *state*: that it says GitHub cancels queued jobs after 24 hours,
935/// so a prolonged outage loses queued work. That bound is stated here rather
936/// than left for a reader to infer, because an operator who does not know it has
937/// no reason to treat a long outage as urgent.
938#[derive(Debug, Clone, Copy, PartialEq, Eq)]
939pub struct OfflineState {
940    /// The unbroken run of failed polls.
941    pub consecutive: u32,
942    /// How long until the next attempt.
943    pub retry_in: Duration,
944    /// How long this loop has been unable to reach GitHub, when it is known.
945    pub offline_for: Option<Duration>,
946}
947
948impl OfflineState {
949    #[must_use]
950    pub const fn new(consecutive: u32, retry_in: Duration) -> Self {
951        Self {
952            consecutive,
953            retry_in,
954            offline_for: None,
955        }
956    }
957
958    #[must_use]
959    pub const fn since(mut self, offline_for: Duration) -> Self {
960        self.offline_for = Some(offline_for);
961        self
962    }
963
964    /// Whether the outage has already outlasted GitHub's queue.
965    ///
966    /// `false` when the duration is unknown: this reports a fact, and "we cannot
967    /// tell" is not the same fact as "not yet".
968    #[must_use]
969    pub fn has_outlasted_the_queue(&self) -> bool {
970        self.offline_for
971            .is_some_and(|elapsed| elapsed >= GITHUB_CANCELS_QUEUED_JOBS_AFTER)
972    }
973}
974
975impl fmt::Display for OfflineState {
976    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
977        write!(
978            f,
979            "GitHub is unreachable; no new runners are being started and running \
980             runners are left alone. Retrying in {}s",
981            self.retry_in.as_secs()
982        )?;
983        if self.has_outlasted_the_queue() {
984            f.write_str(
985                ". This outage has lasted more than 24 hours, and GitHub cancels a queued \
986                 job after 24 hours, so queued work has been lost",
987            )
988        } else {
989            f.write_str(
990                ". GitHub cancels a queued job after 24 hours, so an outage longer than \
991                 that loses queued work",
992            )
993        }
994    }
995}
996
997// ---------------------------------------------------------------------------
998// The launcher port
999// ---------------------------------------------------------------------------
1000
1001/// What this loop asks `e3` to create.
1002#[derive(Debug, Clone, Copy)]
1003pub struct LaunchRequest<'a> {
1004    pub host: &'a Host,
1005    pub policy: &'a ScalePolicy,
1006    /// Proof that e1 owns the host allocation lock while e3 reserves the
1007    /// runner and prunes what is obsolete.
1008    // Crate-visible so only this allocator can mint the request that reaches
1009    // package pruning. A caller holding an unrelated public AllocationLock can
1010    // no longer assemble a LaunchRequest and present that guard as authority.
1011    pub(crate) allocation_guard: &'a AllocationGuard,
1012}
1013
1014/// Why one runner could not be started.
1015///
1016/// Carries `b1`'s [`FailureReason`] rather than a taxonomy of this module's own:
1017/// the reasons a runner fails to start are `e3`'s to know and `b1`'s to name,
1018/// and a third vocabulary here would be a third answer to a question the
1019/// operator asks once.
1020#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1021#[error("the runner could not be started: {reason}")]
1022pub struct LaunchFailure {
1023    pub reason: FailureReason,
1024}
1025
1026/// A lifecycle conclusion that must return through ordinary demand and
1027/// capacity allocation before another runner may start.
1028#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1029pub struct ReplacementIntent {
1030    pub policy: PolicyId,
1031    pub previous_attempt: AttemptId,
1032    pub operation: &'static str,
1033}
1034
1035impl LaunchFailure {
1036    #[must_use]
1037    pub const fn new(reason: FailureReason) -> Self {
1038        Self { reason }
1039    }
1040}
1041
1042/// Why the persisted host ceiling could not be read. Closed, non-secret reasons
1043/// remain distinguishable in reports and structured logs.
1044#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1045pub enum HostCapacityError {
1046    MissingHost,
1047    JournalUnreadable,
1048}
1049
1050impl HostCapacityError {
1051    #[must_use]
1052    pub const fn reason(self) -> &'static str {
1053        match self {
1054            Self::MissingHost => "host_missing",
1055            Self::JournalUnreadable => "host_journal_unreadable",
1056        }
1057    }
1058}
1059
1060#[derive(Debug)]
1061enum AllocationFailure {
1062    HostCapacity(HostCapacityError),
1063    Attempts(LaunchFailure),
1064}
1065
1066/// The seam between the decision to start a runner and the act of starting one.
1067///
1068/// `e3` implements this; every test in this file fakes it, which is what makes
1069/// the whole allocator path decidable with no process, no filesystem and no
1070/// network.
1071///
1072/// # Why the attempt set comes through the same port as the launch
1073///
1074/// `b1` makes this argument at [`HostAllocator::from_attempts`] and it applies
1075/// one layer up: the host-wide total (D9) and every per-policy count (D7) are
1076/// two questions asked of **one** set, and a design that let the caller supply
1077/// the set separately from the thing that creates its members is a design in
1078/// which the two can disagree. Worse, it makes `&[]` expressible — and an empty
1079/// attempt set is exactly the shape that drops the `- active_owned_runners`
1080/// term, starts a second runner for a job already being served, and reports no
1081/// error while doing it.
1082///
1083/// So the launcher is asked, under the allocation lock, immediately before each
1084/// runtime is created. There is no second supply point and no cached copy.
1085///
1086/// # The two ways an implementer can say "I hold no attempts"
1087///
1088/// The argument above closes the hole for a *caller*. It stayed open one level
1089/// down for the **implementer**, in two shapes that both oversubscribe the
1090/// machine and neither of which reports anything:
1091///
1092/// * **By failing.** `attempts()` used to be infallible, which left `e3` — which
1093///   reads a journal off a disk — a choice between panicking and answering
1094///   `vec![]` on an I/O error. An empty set is indistinguishable from an idle
1095///   host, so a transient read failure reads as "nothing is running" and the
1096///   next pass allocates the whole machine for jobs already being served. It is
1097///   fallible now, and [`Reconciler`] treats a failure the way it treats a lock
1098///   it could not take: start nothing, say so, try again next pass.
1099/// * **By lagging.** [`Self::launch`] returns the attempt it created rather than
1100///   its identifier, so the caller can carry it. See that method for the
1101///   measurement that made this necessary.
1102#[async_trait::async_trait]
1103pub trait RunnerLauncher: fmt::Debug + Send + Sync {
1104    /// Read this host's current ceiling from the journal, including edits made
1105    /// after daemon startup. An unreadable or missing host is an error, never
1106    /// permission to fall back to the startup ceiling.
1107    async fn host_capacity(&self, host: &Host) -> Result<NonZeroU16, HostCapacityError>;
1108
1109    /// Reconcile this policy's existing processes before demand is read and
1110    /// capacity is recomputed. A concluded pre-acceptance attempt thereby
1111    /// becomes an ordinary allocation candidate in this same pass; replacement
1112    /// never bypasses the allocator.
1113    async fn supervise(
1114        &self,
1115        _policy: &ScalePolicy,
1116    ) -> Result<Vec<ReplacementIntent>, LaunchFailure> {
1117        Ok(Vec::new())
1118    }
1119
1120    /// Every attempt this host holds, across every policy, terminal ones
1121    /// included.
1122    ///
1123    /// Terminal attempts are included rather than filtered out because the
1124    /// caller needs both answers from one set:
1125    /// [`AttemptState::counts_against_capacity`] decides the ceiling, and the
1126    /// terminal ones are what [`RunnerLauncher::clean`] is for.
1127    ///
1128    /// # Errors
1129    /// [`LaunchFailure`] when the set could not be read. **Never answer `Ok`
1130    /// with an empty vector to signal a failure** — the caller cannot tell that
1131    /// from an idle host, and the two lead to opposite actions.
1132    async fn attempts(&self) -> Result<Vec<RunnerAttempt>, LaunchFailure>;
1133
1134    /// Create exactly one runtime and start one runner, and return the attempt
1135    /// that now exists.
1136    ///
1137    /// Called once per grant, with the host-wide allocation lock held.
1138    ///
1139    /// # The attempt is returned, not just its identifier
1140    ///
1141    /// The host ceiling is enforced against a host-wide total, and that total is
1142    /// recomputed from [`Self::attempts`] on every hold. If a launch is not yet
1143    /// visible there when the *next* policy is allocated for — a journal write
1144    /// that has not landed, an asynchronous store, a cache — then that policy's
1145    /// grant is computed from a set missing the previous policy's runners, and
1146    /// it is too large.
1147    ///
1148    /// That is measured, not hypothetical. With a launcher whose attempts never
1149    /// became visible, two policies on a host of **three** started **six**
1150    /// runners, with the allocation lock held correctly throughout:
1151    /// `host_capacity=3, started=6, launches=6`. Serialisation was never the
1152    /// problem; the arithmetic under it was reading a stale set.
1153    ///
1154    /// So an implementer *should* make the new attempt visible to
1155    /// [`Self::attempts`] before returning — and the caller does not depend on
1156    /// it. [`Reconciler`] carries what this pass created and merges it, by
1157    /// [`RunnerAttempt::id`], with whatever the launcher reports. A launcher
1158    /// that honours the contract is not double-counted, and one that lags cannot
1159    /// oversubscribe the host.
1160    ///
1161    /// # Every call must return a **fresh** [`RunnerAttempt::id`]
1162    ///
1163    /// This is a requirement, not a convention, because the merge above is what
1164    /// carries the host ceiling and the merge is keyed on the identifier. Two
1165    /// calls that answer with the same id are two runtimes that the host-wide
1166    /// total counts once, and the machine is then allocated past
1167    /// `host_capacity`: probed at `host_capacity = 3` with one slot already
1168    /// busy and a launcher answering with a duplicate id, the pass started
1169    /// **four** runners for five occupied slots.
1170    ///
1171    /// That is a narrower defect than the lagging launcher above — that one
1172    /// needed no bug at all, this one needs a broken id generator — but `e3` is
1173    /// the implementor and cannot honour a requirement nobody states.
1174    /// [`Reconciler::host_attempts`] carries a `debug_assert` that fires on a
1175    /// collision, so a development build finds it at the first duplicate rather
1176    /// than through an oversubscribed host.
1177    ///
1178    /// # Errors
1179    /// [`LaunchFailure`], carrying the [`FailureReason`] `e3` recorded.
1180    ///
1181    /// Called under the allocation lock, so it stops at what must be decided
1182    /// there: the attempt, journalled, holding its slot. The runner itself is
1183    /// [`Self::finish_launch`], outside the lock.
1184    async fn launch(&self, request: LaunchRequest<'_>) -> Result<RunnerAttempt, LaunchFailure>;
1185
1186    /// Bring up a runner [`Self::launch`] reserved, after the host allocation
1187    /// lock was released: its package, its registration and its process, none
1188    /// of which decides a ceiling, and all of which may take minutes.
1189    ///
1190    /// The reservation is what holds the slot, and it is journalled, so another
1191    /// allocator counts it from the moment the lock is let go, and a crash at
1192    /// any point here leaves a journalled attempt that startup recovery
1193    /// concludes. A launcher whose `launch` does everything keeps the default.
1194    ///
1195    /// # Errors
1196    /// [`LaunchFailure`], carrying the [`FailureReason`] recorded.
1197    async fn finish_launch(
1198        &self,
1199        _policy: &ScalePolicy,
1200        attempt: RunnerAttempt,
1201        _waits: ReservationWaits,
1202    ) -> Result<RunnerAttempt, LaunchFailure> {
1203        Ok(attempt)
1204    }
1205
1206    /// Remove a terminal attempt's runtime and mark it `cleaned`.
1207    ///
1208    /// Never called for a non-terminal attempt: capacity is reclaimed when an
1209    /// attempt reaches a terminal state and at no other time.
1210    ///
1211    /// # Errors
1212    /// [`LaunchFailure`], carrying the [`FailureReason`] `e3` recorded.
1213    async fn clean(&self, attempt: AttemptId) -> Result<(), LaunchFailure>;
1214}
1215
1216/// A [`RunnerLauncher`] that refuses every launch with
1217/// [`FailureReason::HostUnfit`] while `unfit` is set, and passes everything
1218/// else straight through.
1219///
1220/// The daemon sets the flag while a required host check fails (`host
1221/// doctor`). The refusal comes before the inner launcher runs, so nothing is
1222/// registered with GitHub, and the reconciler reports it as a
1223/// `runner_start_failed` event with reason `host_unfit` -- visible, rather than
1224/// a host that silently stopped serving. Supervision, the attempt set and
1225/// cleanup are never gated: what already runs is still looked after.
1226#[derive(Debug)]
1227pub struct FitnessGatedLauncher {
1228    inner: Arc<dyn RunnerLauncher>,
1229    unfit: Arc<AtomicBool>,
1230}
1231
1232impl FitnessGatedLauncher {
1233    #[must_use]
1234    pub const fn new(inner: Arc<dyn RunnerLauncher>, unfit: Arc<AtomicBool>) -> Self {
1235        Self { inner, unfit }
1236    }
1237}
1238
1239#[async_trait::async_trait]
1240impl RunnerLauncher for FitnessGatedLauncher {
1241    async fn host_capacity(&self, host: &Host) -> Result<NonZeroU16, HostCapacityError> {
1242        self.inner.host_capacity(host).await
1243    }
1244
1245    async fn supervise(
1246        &self,
1247        policy: &ScalePolicy,
1248    ) -> Result<Vec<ReplacementIntent>, LaunchFailure> {
1249        self.inner.supervise(policy).await
1250    }
1251
1252    async fn attempts(&self) -> Result<Vec<RunnerAttempt>, LaunchFailure> {
1253        self.inner.attempts().await
1254    }
1255
1256    async fn launch(&self, request: LaunchRequest<'_>) -> Result<RunnerAttempt, LaunchFailure> {
1257        if self.unfit.load(Ordering::Acquire) {
1258            return Err(LaunchFailure::new(FailureReason::HostUnfit));
1259        }
1260        self.inner.launch(request).await
1261    }
1262
1263    async fn finish_launch(
1264        &self,
1265        policy: &ScalePolicy,
1266        attempt: RunnerAttempt,
1267        waits: ReservationWaits,
1268    ) -> Result<RunnerAttempt, LaunchFailure> {
1269        self.inner.finish_launch(policy, attempt, waits).await
1270    }
1271
1272    async fn clean(&self, attempt: AttemptId) -> Result<(), LaunchFailure> {
1273        self.inner.clean(attempt).await
1274    }
1275}
1276
1277// ---------------------------------------------------------------------------
1278// The host-wide allocation lock
1279// ---------------------------------------------------------------------------
1280
1281/// The lock is held for as long as this value lives.
1282///
1283/// Opaque on purpose: what is being held differs between the in-process and the
1284/// file-backed implementation, and a caller that could see which one it has
1285/// would eventually branch on it.
1286pub struct AllocationGuard {
1287    _held: Box<dyn std::any::Any + Send + Sync>,
1288    launch_hold: LaunchHold,
1289}
1290
1291impl AllocationGuard {
1292    /// Wrap whatever the implementation holds. Dropping the guard drops it.
1293    #[must_use]
1294    fn new<T: Send + Sync + 'static>(held: T) -> Self {
1295        Self {
1296            _held: Box::new(held),
1297            launch_hold: LaunchHold::default(),
1298        }
1299    }
1300
1301    /// What a launch keeps after the lock is released; see [`LaunchHold`].
1302    fn launch_hold(&self) -> LaunchHold {
1303        self.launch_hold.clone()
1304    }
1305}
1306
1307/// What must stay held while a runner reserved under the allocation lock is
1308/// brought up outside it. Empty, except where a boundary other than the lock
1309/// must not be crossed by a launch in progress: see
1310/// [`WslRecoveryAllocationLock`].
1311#[derive(Clone, Default)]
1312struct LaunchHold {
1313    _held: Option<Arc<dyn std::any::Any + Send + Sync>>,
1314}
1315
1316impl fmt::Debug for AllocationGuard {
1317    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1318        f.write_str("AllocationGuard")
1319    }
1320}
1321
1322/// The host-wide allocation lock could not be taken.
1323#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1324#[error("the host-wide allocation lock is held by another allocator; no runtime was created")]
1325pub struct AllocationLockBusy;
1326
1327/// Flow 2.4's *"takes the host-wide allocation lock before creating each local
1328/// runtime"*, as a port.
1329///
1330/// # Why a lock is needed at all, given the allocator already exists
1331///
1332/// [`HostAllocator`] enforces D9 across the policies of **one** pass. It cannot
1333/// enforce anything across two passes running at once, and `f3` runs one
1334/// demand-polling loop per target: without serialisation, two loops read the
1335/// same headroom, each finds it sufficient, and the host ends up with the sum of
1336/// two grants it only ever had room for one of. The lock is what makes the
1337/// read-decide-create sequence atomic, and it is taken once per runtime rather
1338/// than once per pass so that a slow package download in one policy does not
1339/// hold the whole host still.
1340#[async_trait::async_trait]
1341pub trait AllocationLock: fmt::Debug + Send + Sync {
1342    /// Take the lock, waiting briefly for it.
1343    ///
1344    /// # Errors
1345    /// [`AllocationLockBusy`] when it could not be taken. A refused grant is
1346    /// always safe: the next pass re-reads the headroom and tries again.
1347    async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy>;
1348}
1349
1350/// The lock every task inside one agent process contends for.
1351///
1352/// This is the implementation that matters in practice, because the
1353/// single-instance lock (`d1`) already guarantees one agent per host: the
1354/// concurrency the allocation lock actually has to serialise is `f3`'s
1355/// per-target loops inside that one process. A `tokio` mutex rather than a
1356/// `std` one because it is held across the `await` that creates the runtime.
1357#[derive(Debug, Default)]
1358pub struct InProcessAllocationLock {
1359    mutex: Arc<tokio::sync::Mutex<()>>,
1360}
1361
1362impl InProcessAllocationLock {
1363    #[must_use]
1364    pub fn new() -> Self {
1365        Self::default()
1366    }
1367}
1368
1369#[async_trait::async_trait]
1370impl AllocationLock for InProcessAllocationLock {
1371    async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
1372        let mutex = Arc::clone(&self.mutex);
1373        let guard = mutex.lock_owned().await;
1374        Ok(AllocationGuard::new(guard))
1375    }
1376}
1377
1378/// `d1`'s file lock, which is host-wide across processes as well as across
1379/// tasks.
1380///
1381/// Defence in depth behind [`InProcessAllocationLock`], for the configuration
1382/// `d1` documents as the one where two agents can genuinely coexist: the
1383/// platform state directory is per-account, so a service-account daemon and an
1384/// interactive `daemon run` resolve different paths and do not contend for the
1385/// single-instance lock. They do contend here if they share a state directory.
1386///
1387/// [`runner_manager_platform::lock::HostLock::acquire`] blocks the calling
1388/// thread and its own documentation names this caller: *"Async callers must wrap
1389/// it in [`tokio::task::spawn_blocking`]"*. That is what this does, and the
1390/// returned `HostLock` lives inside the guard, because dropping it is the
1391/// release.
1392#[derive(Debug, Clone)]
1393pub struct FileAllocationLock {
1394    paths: Arc<runner_manager_platform::paths::AppPaths>,
1395    wait: Duration,
1396}
1397
1398impl FileAllocationLock {
1399    #[must_use]
1400    pub const fn new(paths: Arc<runner_manager_platform::paths::AppPaths>) -> Self {
1401        Self {
1402            paths,
1403            wait: ALLOCATION_LOCK_WAIT,
1404        }
1405    }
1406
1407    #[must_use]
1408    pub const fn with_wait(mut self, wait: Duration) -> Self {
1409        self.wait = wait;
1410        self
1411    }
1412}
1413
1414#[async_trait::async_trait]
1415impl AllocationLock for FileAllocationLock {
1416    async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
1417        use runner_manager_platform::lock::{HostLock, LockKind};
1418
1419        let paths = Arc::clone(&self.paths);
1420        let wait = self.wait;
1421        let held = tokio::task::spawn_blocking(move || {
1422            HostLock::acquire(&paths, LockKind::Allocation, wait)
1423        })
1424        .await;
1425
1426        match held {
1427            Ok(Ok(lock)) => Ok(AllocationGuard::new(lock)),
1428            // A refused lock and a panicked blocking task are the same outcome
1429            // to this caller: no runtime was created and the next pass will
1430            // re-read the headroom. Neither is allowed to look like a grant.
1431            Ok(Err(_)) | Err(_) => Err(AllocationLockBusy),
1432        }
1433    }
1434}
1435
1436/// Adds the Windows/WSL recovery fence to an ordinary allocation lock, and
1437/// says on disk when allocations keep being refused.
1438///
1439/// A Windows watchdog writes its drain request before attempting the same
1440/// atomic directory claim. Therefore either this process owns the directory and
1441/// finishes the launches already in progress, or Windows owns it and no new
1442/// launch can pass. The claim is shared by every launch of this process, from
1443/// its reservation until its runner is up ([`LaunchHold`]), and released with
1444/// the last: a drain request refuses new reservations, so the launches in
1445/// progress run out and Windows gets the directory, as when there was one.
1446/// A malformed configured fence fails closed. A guest claim
1447/// whose owner is provably gone is reclaimed first; see
1448/// [`runner_manager_platform::wsl::fence`].
1449///
1450/// # Refusals that persist are recorded
1451///
1452/// A refusal is always safe for one pass, and a refusal on every pass is a host
1453/// that starts nothing while it looks healthy -- 13 days of it, once. So after
1454/// [`launch_health::BLOCKED_AFTER_DEFERRALS`] consecutive refusals spanning at
1455/// least [`launch_health::BLOCKED_AFTER`], with no launch of this process in
1456/// progress, the lock writes the `state/` record `status`, `service status`,
1457/// `wsl status` and `host doctor` read. The next granted allocation removes it.
1458/// Refusals while this process is itself launching are its own contention and
1459/// count neither way.
1460///
1461/// [`launch_health::BLOCKED_AFTER_DEFERRALS`]: runner_manager_platform::launch_health::BLOCKED_AFTER_DEFERRALS
1462/// [`launch_health::BLOCKED_AFTER`]: runner_manager_platform::launch_health::BLOCKED_AFTER
1463#[derive(Debug)]
1464pub struct WslRecoveryAllocationLock {
1465    paths: Arc<runner_manager_platform::paths::AppPaths>,
1466    inner: Arc<dyn AllocationLock>,
1467    blocked_after_refusals: u32,
1468    blocked_after: Duration,
1469    /// A quiet spell this long between refusals starts a new run.
1470    streak_gap: Duration,
1471    in_flight: Arc<std::sync::atomic::AtomicUsize>,
1472    /// The claim the launches in progress share; taking it is serialised.
1473    claim: tokio::sync::Mutex<std::sync::Weak<LaunchFenceGuard>>,
1474    refusals: Mutex<Refusals>,
1475}
1476
1477/// The refusals since the last grant.
1478#[derive(Debug, Default)]
1479struct Refusals {
1480    /// The current run of refusals: when it began, the latest one, and how
1481    /// many it holds.
1482    streak: Option<Streak>,
1483    recorded: bool,
1484}
1485
1486#[derive(Debug, Clone, Copy)]
1487struct Streak {
1488    since: chrono::DateTime<chrono::Utc>,
1489    latest: chrono::DateTime<chrono::Utc>,
1490    count: u32,
1491}
1492
1493/// Counts a launch of this process as in progress until dropped.
1494struct InFlight(Arc<std::sync::atomic::AtomicUsize>);
1495
1496impl InFlight {
1497    fn enter(count: &Arc<std::sync::atomic::AtomicUsize>) -> Self {
1498        count.fetch_add(1, Ordering::SeqCst);
1499        Self(Arc::clone(count))
1500    }
1501}
1502
1503impl Drop for InFlight {
1504    fn drop(&mut self) {
1505        self.0.fetch_sub(1, Ordering::SeqCst);
1506    }
1507}
1508
1509/// Releases the guest launch claim when the allocation ends, and says so when
1510/// it cannot: this once failed silently while a distribution started nothing
1511/// for 13 days.
1512struct LaunchFenceGuard(Option<runner_manager_platform::wsl::fence::GuestLaunchClaim>);
1513
1514impl Drop for LaunchFenceGuard {
1515    fn drop(&mut self) {
1516        if let Some(claim) = self.0.take()
1517            && let Err(error) = claim.release()
1518        {
1519            tracing::warn!(
1520                %error,
1521                "the WSL launch fence could not be released; the next launch reclaims it"
1522            );
1523        }
1524    }
1525}
1526
1527impl WslRecoveryAllocationLock {
1528    #[must_use]
1529    pub fn new(
1530        paths: Arc<runner_manager_platform::paths::AppPaths>,
1531        inner: Arc<dyn AllocationLock>,
1532    ) -> Self {
1533        use runner_manager_platform::launch_health::{BLOCKED_AFTER, BLOCKED_AFTER_DEFERRALS};
1534        Self {
1535            paths,
1536            inner,
1537            blocked_after_refusals: BLOCKED_AFTER_DEFERRALS,
1538            blocked_after: BLOCKED_AFTER,
1539            streak_gap: BLOCKED_AFTER,
1540            in_flight: Arc::default(),
1541            claim: tokio::sync::Mutex::default(),
1542            refusals: Mutex::default(),
1543        }
1544    }
1545
1546    /// Record blocked launches after `refusals` consecutive refusals spanning
1547    /// at least `after`, instead of the defaults.
1548    #[cfg(test)]
1549    const fn with_blocked_after(mut self, refusals: u32, after: Duration) -> Self {
1550        self.blocked_after_refusals = refusals;
1551        self.blocked_after = after;
1552        self
1553    }
1554
1555    /// Start a new run of refusals after a quiet spell of `gap`.
1556    #[cfg(test)]
1557    const fn with_streak_gap(mut self, gap: Duration) -> Self {
1558        self.streak_gap = gap;
1559        self
1560    }
1561
1562    fn refusals(&self) -> std::sync::MutexGuard<'_, Refusals> {
1563        self.refusals
1564            .lock()
1565            .unwrap_or_else(std::sync::PoisonError::into_inner)
1566    }
1567
1568    fn note_granted(&self) {
1569        let mut refusals = self.refusals();
1570        refusals.streak = None;
1571        if refusals.recorded {
1572            match runner_manager_platform::launch_health::clear_launches_blocked(&self.paths) {
1573                Ok(()) => refusals.recorded = false,
1574                // Kept, so the next grant tries again.
1575                Err(error) => tracing::warn!(%error, "the blocked-launch record cannot be cleared"),
1576            }
1577        }
1578    }
1579
1580    fn note_refused(&self, cause: &runner_manager_platform::launch_health::BlockCause) {
1581        use runner_manager_platform::launch_health::{Viewpoint, record_launches_blocked};
1582        use runner_manager_platform::wsl::fence::elapsed_at_least;
1583
1584        if self.in_flight.load(Ordering::SeqCst) > 0 {
1585            return;
1586        }
1587        let now = chrono::Utc::now();
1588        let mut refusals = self.refusals();
1589        // A refusal long after the previous one starts a new run: two brief
1590        // contentions days apart are not five minutes of nothing but refusals.
1591        let streak = match refusals.streak {
1592            Some(streak) if !elapsed_at_least(streak.latest, now, self.streak_gap) => Streak {
1593                latest: now,
1594                count: streak.count.saturating_add(1),
1595                ..streak
1596            },
1597            _ => Streak {
1598                since: now,
1599                latest: now,
1600                count: 1,
1601            },
1602        };
1603        refusals.streak = Some(streak);
1604        if streak.count < self.blocked_after_refusals
1605            || !elapsed_at_least(streak.since, now, self.blocked_after)
1606        {
1607            return;
1608        }
1609        let blocked = cause.blocked_since(streak.since, Viewpoint::Guest);
1610        if !refusals.recorded {
1611            tracing::warn!(
1612                refusals = streak.count,
1613                "runner launches are blocked {blocked}: {}",
1614                blocked.remedy
1615            );
1616        }
1617        // Rewritten on every refusal, so readers can tell a record nobody
1618        // refreshes -- no demand any more -- from one still being refused.
1619        match record_launches_blocked(&self.paths, &blocked, now) {
1620            Ok(()) => refusals.recorded = true,
1621            Err(error) => tracing::warn!(%error, "the blocked-launch record cannot be written"),
1622        }
1623    }
1624}
1625
1626/// Claim the guest launch fence under `root`, or say what holds it.
1627fn claim_guest_fence(
1628    root: &std::path::Path,
1629) -> Result<Arc<LaunchFenceGuard>, runner_manager_platform::launch_health::BlockCause> {
1630    use runner_manager_platform::launch_health::BlockCause;
1631    use runner_manager_platform::wsl::fence::{
1632        FENCE_DIRECTORY, FenceClaim, GuestClaimAttempt, try_claim_guest_launch,
1633    };
1634
1635    match try_claim_guest_launch(root) {
1636        Ok(GuestClaimAttempt::Claimed { claim, reclaimed }) => {
1637            if let Some(reclaimed) = reclaimed {
1638                tracing::warn!(
1639                    shared_root = %root.display(),
1640                    %reclaimed,
1641                    "reclaimed a WSL launch fence claim whose owner is gone"
1642                );
1643            }
1644            // Guarded at once, so a claim let go on the drain path is released
1645            // -- and logged when it cannot be -- like any other.
1646            Ok(Arc::new(LaunchFenceGuard(Some(claim))))
1647        }
1648        Ok(GuestClaimAttempt::Busy) => {
1649            let directory = root.join(FENCE_DIRECTORY);
1650            Err(match FenceClaim::owner(root) {
1651                Ok(owner) => BlockCause::FenceHeld { directory, owner },
1652                Err(error) => BlockCause::FenceOwnerUnreadable {
1653                    directory,
1654                    detail: error.to_string(),
1655                },
1656            })
1657        }
1658        Err(error) => {
1659            // An owner record nobody can decode stops the reclaim the same way
1660            // on every pass; name it rather than the config.
1661            Err(match FenceClaim::owner(root) {
1662                Err(owner_error) => BlockCause::FenceOwnerUnreadable {
1663                    directory: root.join(FENCE_DIRECTORY),
1664                    detail: owner_error.to_string(),
1665                },
1666                Ok(_) => BlockCause::FenceUnusable {
1667                    detail: error.to_string(),
1668                },
1669            })
1670        }
1671    }
1672}
1673
1674#[async_trait::async_trait]
1675impl AllocationLock for WslRecoveryAllocationLock {
1676    async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
1677        use runner_manager_platform::launch_health::BlockCause;
1678        use runner_manager_platform::wsl::fence::{DrainRequest, GuestRecoveryConfig};
1679
1680        let paths = Arc::clone(&self.paths);
1681        let mut shared = self.claim.lock().await;
1682        let held = shared.upgrade();
1683        let claim = tokio::task::spawn_blocking(move || {
1684            let unusable = |error: &dyn fmt::Display| BlockCause::FenceUnusable {
1685                detail: error.to_string(),
1686            };
1687            let config = match GuestRecoveryConfig::read(&paths) {
1688                Ok(Some(config)) => config,
1689                Ok(None) => return Ok(Arc::new(LaunchFenceGuard(None))),
1690                Err(error) => return Err(unusable(&error)),
1691            };
1692            let root = config.shared_root;
1693            let claim = match held {
1694                // A launch of this process already owns the directory.
1695                Some(held) => {
1696                    if let Some(Err(error)) = held.0.as_ref().map(|claim| claim.renew()) {
1697                        tracing::warn!(%error, "the WSL launch fence's age could not be renewed");
1698                    }
1699                    held
1700                }
1701                None => claim_guest_fence(&root)?,
1702            };
1703            match DrainRequest::read(&root) {
1704                Ok(None) => Ok(claim),
1705                Ok(Some(_)) => Err(BlockCause::DrainRequested { root }),
1706                Err(error) => Err(unusable(&error)),
1707            }
1708        })
1709        .await
1710        .map_err(|_| AllocationLockBusy)?;
1711
1712        let claim = match claim {
1713            Ok(claim) => claim,
1714            Err(cause) => {
1715                drop(shared);
1716                self.note_refused(&cause);
1717                return Err(AllocationLockBusy);
1718            }
1719        };
1720        if claim.0.is_some() {
1721            *shared = Arc::downgrade(&claim);
1722        }
1723        drop(shared);
1724        let Ok(inner) = self.inner.acquire().await else {
1725            self.note_refused(&BlockCause::AllocationLockBusy {
1726                path: self
1727                    .paths
1728                    .state_dir()
1729                    .join(runner_manager_platform::lock::LockKind::Allocation.file_name()),
1730            });
1731            return Err(AllocationLockBusy);
1732        };
1733        self.note_granted();
1734        let in_flight = InFlight::enter(&self.in_flight);
1735        let mut guard = AllocationGuard::new(inner);
1736        guard.launch_hold = LaunchHold {
1737            _held: Some(Arc::new((claim, in_flight))),
1738        };
1739        Ok(guard)
1740    }
1741}
1742
1743// ---------------------------------------------------------------------------
1744// Lifecycle events
1745// ---------------------------------------------------------------------------
1746
1747/// Which terminal thing happened, as a closed vocabulary.
1748///
1749/// The distinction `g2` renders: an idle exit is the accepted surplus case and
1750/// **not** a failure, and showing it as one sends an operator hunting a fault
1751/// that does not exist.
1752#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1753pub enum OutcomeKind {
1754    CompletedJob,
1755    IdleExit,
1756    Failed,
1757    Orphaned,
1758}
1759
1760impl OutcomeKind {
1761    #[must_use]
1762    pub const fn of(outcome: &AttemptOutcome) -> Self {
1763        match outcome {
1764            AttemptOutcome::CompletedJob => Self::CompletedJob,
1765            AttemptOutcome::ExitedIdleWithoutWork => Self::IdleExit,
1766            AttemptOutcome::Failed { .. } => Self::Failed,
1767            AttemptOutcome::Orphaned => Self::Orphaned,
1768        }
1769    }
1770
1771    #[must_use]
1772    pub const fn is_failure(&self) -> bool {
1773        matches!(self, Self::Failed | Self::Orphaned)
1774    }
1775
1776    #[must_use]
1777    pub const fn as_str(&self) -> &'static str {
1778        match self {
1779            Self::CompletedJob => "completed_job",
1780            Self::IdleExit => "exited_idle_without_work",
1781            Self::Failed => "failed",
1782            Self::Orphaned => "orphaned",
1783        }
1784    }
1785}
1786
1787impl fmt::Display for OutcomeKind {
1788    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1789        f.write_str(self.as_str())
1790    }
1791}
1792
1793/// A [`FailureReason`]'s variant name, with no detail.
1794///
1795/// [`FailureReason::Other`] carries a `String` that `e3` fills in, and an event
1796/// is not the place for it: `07-security.md`'s log scan runs over everything
1797/// this loop emits, and free text is the one shape that can carry a credential
1798/// past a field allow-list. The operator-facing detail reaches the journal
1799/// through `b2` and the screen through `g2`; what reaches an *event* is the
1800/// variant.
1801#[must_use]
1802pub const fn failure_reason_kind(reason: &FailureReason) -> &'static str {
1803    match reason {
1804        FailureReason::JitRequestFailed => "jit_request_failed",
1805        FailureReason::JitExpired => "jit_expired",
1806        FailureReason::RunnerPackageUnverified => "runner_package_unverified",
1807        FailureReason::RunnerVersionRejected => "runner_version_rejected",
1808        FailureReason::ProcessStartFailed => "process_start_failed",
1809        FailureReason::ProcessExitedUnexpectedly => "process_exited_unexpectedly",
1810        FailureReason::RegistrationTimedOut => "registration_timed_out",
1811        FailureReason::TerminatedAfterRegistrationTimeout => {
1812            "terminated_after_registration_timeout"
1813        }
1814        FailureReason::IsolationProvider(category) => match category {
1815            IsolationProviderFailure::Unsupported => "isolation_provider_unsupported",
1816            IsolationProviderFailure::NotInstalled => "isolation_provider_not_installed",
1817            IsolationProviderFailure::PermissionDenied => "isolation_provider_permission_denied",
1818            IsolationProviderFailure::ImageUnavailableOrIncompatible => {
1819                "isolation_image_unavailable"
1820            }
1821            IsolationProviderFailure::DiskQuotaUnavailable => "isolation_disk_quota_unavailable",
1822            IsolationProviderFailure::Degraded => "isolation_provider_degraded",
1823            IsolationProviderFailure::RuntimeOperationFailed => {
1824                "isolation_runtime_operation_failed"
1825            }
1826            IsolationProviderFailure::OwnershipMismatch => "isolation_ownership_mismatch",
1827            IsolationProviderFailure::UnsafeRuntimePath => "isolation_unsafe_runtime_path",
1828            IsolationProviderFailure::JitHandoffRejected => "isolation_jit_handoff_rejected",
1829        },
1830        FailureReason::WorkspaceCleanupDeferred => "ephemeral_workspace_could_not_be_removed",
1831        FailureReason::HostUnfit => "host_unfit",
1832        FailureReason::Other(_) => "other",
1833    }
1834}
1835
1836/// Fixed, credential-free reasons a queued job cannot route to one profile.
1837#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
1838pub enum RoutingRefusal {
1839    OverlappingProfiles,
1840}
1841
1842impl RoutingRefusal {
1843    #[must_use]
1844    pub const fn as_str(self) -> &'static str {
1845        match self {
1846            Self::OverlappingProfiles => "overlapping_profile_matches",
1847        }
1848    }
1849}
1850
1851impl fmt::Display for RoutingRefusal {
1852    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1853        f.write_str(match self {
1854            Self::OverlappingProfiles => {
1855                "repair overlapping profile labels or require a unique profile selector"
1856            }
1857        })
1858    }
1859}
1860
1861/// What `g2`'s activity view and the local log sink see.
1862///
1863/// **Every field is an identifier, a count, a duration, or a `&'static str`
1864/// drawn from a closed set.** There is no `String` anywhere in this enum, which
1865/// is what makes "no emitted event contains a token, a JIT blob, or a credential
1866/// header" a property of the type rather than a discipline each call site has to
1867/// keep. `tests::no_emitted_event_can_carry_a_credential` renders every variant
1868/// through `d1`'s scrubber and asserts nothing changes, with a positive control
1869/// so the assertion cannot pass vacuously.
1870#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1871pub enum LifecycleEvent {
1872    /// A demand poll answered for one target.
1873    DemandObserved {
1874        policy: PolicyId,
1875        /// Queued jobs this policy's routing labels match. The number clamped.
1876        demand: u32,
1877        /// Queued jobs whose required labels this policy does not carry.
1878        ///
1879        /// Never demand. Reported because the difference between this and
1880        /// `demand` is the whole value of the label filtering, and an operator
1881        /// wondering why a busy repository started no runners is owed it.
1882        not_matched: u32,
1883        /// Queued jobs whose `runs-on` could not be resolved statically.
1884        ///
1885        /// `b1` requires these be "reported as unresolvable rather than silently
1886        /// counted or silently dropped": counting one would start a runner for a
1887        /// job that may not be ours, and dropping it would hide a workflow this
1888        /// host can never serve. A count rather than the reasons themselves
1889        /// because this type is `Copy`, and `c4` logs the reasons where it
1890        /// builds them.
1891        unresolvable: u32,
1892        /// `false` when the count is a floor rather than a total.
1893        complete: bool,
1894    },
1895    /// A queued job cannot be assigned unambiguously to this target's profiles.
1896    RoutingRefused {
1897        policy: PolicyId,
1898        reason: RoutingRefusal,
1899        count: u32,
1900    },
1901    /// A target could not be polled, so its policies start nothing this pass.
1902    TargetUnreadable {
1903        policy: PolicyId,
1904        reason: &'static str,
1905    },
1906    /// One policy's share of the pass.
1907    Allocated {
1908        policy: PolicyId,
1909        demand: u32,
1910        desired: u16,
1911        active_owned: u16,
1912        headroom: u16,
1913        to_start: u16,
1914        limiting: LimitingFactor,
1915    },
1916    /// A monitor-only policy was skipped entirely, before any demand request
1917    /// was issued for it (D19).
1918    MonitorOnlySkipped { policy: PolicyId },
1919    /// One runtime was created and one runner started.
1920    RunnerStarted {
1921        policy: PolicyId,
1922        attempt: AttemptId,
1923    },
1924    /// One runner could not be started.
1925    RunnerStartFailed {
1926        policy: PolicyId,
1927        reason: &'static str,
1928    },
1929    /// The allocation lock was not free, so `count` runners this policy was
1930    /// granted were not created this pass.
1931    AllocationDeferred { policy: PolicyId, count: u16 },
1932    /// The host lacks the memory for one more runner of this policy: free
1933    /// memory less what its running attempts may still grow into is under
1934    /// the estimate plus the margin. Not `started`: the demand stays queued
1935    /// and a later pass tries again. `started`: nothing runs that could free
1936    /// memory, so the runner was started anyway.
1937    MemoryShort {
1938        policy: PolicyId,
1939        figures: MemoryFigures,
1940        started: bool,
1941    },
1942    /// The host's attempt set could not be read at all.
1943    ///
1944    /// Distinct from an empty set on purpose, and the whole reason
1945    /// [`RunnerLauncher::attempts`] is fallible: the two produce the same
1946    /// *number* and demand opposite actions.
1947    AttemptsUnreadable { reason: &'static str },
1948    /// The ceiling is unknown; this does not imply that the attempt set is unreadable.
1949    HostCapacityUnreadable { reason: HostCapacityError },
1950    /// A terminal attempt's runtime was removed.
1951    AttemptCleaned {
1952        policy: PolicyId,
1953        attempt: AttemptId,
1954        outcome: OutcomeKind,
1955    },
1956    /// A terminal attempt's runtime could not be removed. It will be retried on
1957    /// the next pass, and this is what keeps that retry from being silent.
1958    AttemptCleanFailed {
1959        policy: PolicyId,
1960        attempt: AttemptId,
1961        reason: &'static str,
1962    },
1963    /// Scale-down declined to remove a runner that is executing a job.
1964    ScaleDownRefused {
1965        policy: PolicyId,
1966        attempt: AttemptId,
1967    },
1968    /// When the next poll is, and why then.
1969    PollScheduled { retry_in_ms: u64, pace: PollPace },
1970}
1971
1972impl LifecycleEvent {
1973    /// A fixed name, for the `event` field `d1`'s sink allows verbatim.
1974    #[must_use]
1975    pub const fn name(&self) -> &'static str {
1976        match self {
1977            Self::DemandObserved { .. } => "demand_observed",
1978            Self::RoutingRefused { .. } => "routing_refused",
1979            Self::TargetUnreadable { .. } => "target_unreadable",
1980            Self::Allocated { .. } => "allocated",
1981            Self::MonitorOnlySkipped { .. } => "monitor_only_skipped",
1982            Self::RunnerStarted { .. } => "runner_started",
1983            Self::RunnerStartFailed { .. } => "runner_start_failed",
1984            Self::AllocationDeferred { .. } => "allocation_deferred",
1985            Self::MemoryShort { started: false, .. } => "memory_deferred",
1986            Self::MemoryShort { started: true, .. } => "memory_admitted_alone",
1987            Self::AttemptsUnreadable { .. } => "attempts_unreadable",
1988            Self::HostCapacityUnreadable { .. } => "host_capacity_unreadable",
1989            Self::AttemptCleaned { .. } => "attempt_cleaned",
1990            Self::AttemptCleanFailed { .. } => "attempt_clean_failed",
1991            Self::ScaleDownRefused { .. } => "scale_down_refused",
1992            Self::PollScheduled { .. } => "poll_scheduled",
1993        }
1994    }
1995
1996    /// Which policy this event is about.
1997    #[must_use]
1998    pub const fn policy(&self) -> Option<PolicyId> {
1999        match self {
2000            Self::DemandObserved { policy, .. }
2001            | Self::RoutingRefused { policy, .. }
2002            | Self::TargetUnreadable { policy, .. }
2003            | Self::Allocated { policy, .. }
2004            | Self::MonitorOnlySkipped { policy }
2005            | Self::RunnerStarted { policy, .. }
2006            | Self::RunnerStartFailed { policy, .. }
2007            | Self::AllocationDeferred { policy, .. }
2008            | Self::MemoryShort { policy, .. }
2009            | Self::AttemptCleaned { policy, .. }
2010            | Self::AttemptCleanFailed { policy, .. }
2011            | Self::ScaleDownRefused { policy, .. } => Some(*policy),
2012            Self::PollScheduled { .. }
2013            | Self::AttemptsUnreadable { .. }
2014            | Self::HostCapacityUnreadable { .. } => None,
2015        }
2016    }
2017}
2018
2019impl fmt::Display for LifecycleEvent {
2020    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2021        match self {
2022            Self::DemandObserved {
2023                policy,
2024                demand,
2025                not_matched,
2026                unresolvable,
2027                complete,
2028            } => write!(
2029                f,
2030                "policy {policy}: {demand} queued jobs for this host{}{}{}",
2031                if *not_matched == 0 {
2032                    String::new()
2033                } else {
2034                    format!(", {not_matched} for other labels")
2035                },
2036                if *unresolvable == 0 {
2037                    String::new()
2038                } else {
2039                    format!(", {unresolvable} with an unresolvable `runs-on`")
2040                },
2041                if *complete {
2042                    ""
2043                } else {
2044                    " (a floor, not a total)"
2045                }
2046            ),
2047            Self::TargetUnreadable { policy, reason } => {
2048                write!(f, "policy {policy}: target unreadable ({reason})")
2049            }
2050            Self::RoutingRefused {
2051                policy,
2052                reason,
2053                count,
2054            } => write!(f, "policy {policy}: {count} queued jobs blocked: {reason}"),
2055            Self::Allocated {
2056                policy,
2057                demand,
2058                desired,
2059                active_owned,
2060                headroom,
2061                to_start,
2062                limiting,
2063            } => write!(
2064                f,
2065                "policy {policy}: demand {demand}, desired {desired}, {active_owned} in \
2066                 flight, {headroom} free on this host, starting {to_start} ({limiting})"
2067            ),
2068            Self::MonitorOnlySkipped { policy } => {
2069                write!(f, "policy {policy}: monitor-only, skipped")
2070            }
2071            Self::RunnerStarted { policy, attempt } => {
2072                write!(f, "policy {policy}: started attempt {attempt}")
2073            }
2074            Self::RunnerStartFailed { policy, reason } => {
2075                write!(f, "policy {policy}: could not start a runner ({reason})")
2076            }
2077            Self::AllocationDeferred { policy, count } => write!(
2078                f,
2079                "policy {policy}: the allocation lock was held; {count} granted runners \
2080                 were not created"
2081            ),
2082            Self::MemoryShort {
2083                policy,
2084                figures,
2085                started: false,
2086            } => write!(f, "policy {policy}: waiting for memory: {figures}"),
2087            Self::MemoryShort {
2088                policy,
2089                figures,
2090                started: true,
2091            } => write!(
2092                f,
2093                "policy {policy}: started although the host lacks the memory, because \
2094                 nothing else runs here: {figures}"
2095            ),
2096            Self::AttemptsUnreadable { reason } => write!(
2097                f,
2098                "the host's attempt set could not be read ({reason}); nothing was started, \
2099                 and this is not the same as the host being idle"
2100            ),
2101            Self::HostCapacityUnreadable { reason } => write!(
2102                f,
2103                "the host capacity could not be read ({}); new launches wait for readable host settings",
2104                reason.reason()
2105            ),
2106            Self::AttemptCleaned {
2107                policy,
2108                attempt,
2109                outcome,
2110            } => write!(f, "policy {policy}: cleaned attempt {attempt} ({outcome})"),
2111            Self::AttemptCleanFailed {
2112                policy,
2113                attempt,
2114                reason,
2115            } => write!(
2116                f,
2117                "policy {policy}: attempt {attempt} could not be cleaned ({reason}); it \
2118                 will be retried"
2119            ),
2120            Self::ScaleDownRefused { policy, attempt } => write!(
2121                f,
2122                "policy {policy}: attempt {attempt} is executing a job and was not removed"
2123            ),
2124            Self::PollScheduled { retry_in_ms, pace } => {
2125                write!(f, "next poll in {retry_in_ms}ms ({pace})")
2126            }
2127        }
2128    }
2129}
2130
2131/// Where lifecycle events go.
2132pub trait EventSink: fmt::Debug + Send + Sync {
2133    fn emit(&self, event: LifecycleEvent);
2134}
2135
2136/// Discards everything. For callers that only want the report.
2137#[derive(Debug, Clone, Copy, Default)]
2138pub struct NoEvents;
2139
2140impl EventSink for NoEvents {
2141    fn emit(&self, _event: LifecycleEvent) {}
2142}
2143
2144/// The local log sink, through `d1`'s redacting layer.
2145///
2146/// Every field name below is on
2147/// [`runner_manager_platform::logging::ALLOWED_FIELDS`]; anything else would be
2148/// replaced with `[redacted]` and the line would lose its meaning rather than
2149/// its safety. `tests::every_field_name_this_sink_emits_is_one_d1_allows` keeps
2150/// that true.
2151#[derive(Debug, Clone, Copy, Default)]
2152pub struct TracingEvents;
2153
2154impl EventSink for TracingEvents {
2155    fn emit(&self, event: LifecycleEvent) {
2156        let name = event.name();
2157        match event {
2158            LifecycleEvent::DemandObserved {
2159                policy,
2160                demand,
2161                not_matched,
2162                unresolvable,
2163                complete,
2164            } => {
2165                // An empty reading is the idle steady state, and the idle
2166                // interval repeats it every few seconds per target: at `info`
2167                // it would be most of the log. Anything worth reading is still
2168                // `info`.
2169                if demand == 0 && not_matched == 0 && unresolvable == 0 && complete {
2170                    tracing::debug!(
2171                        event = name,
2172                        policy_id = %policy,
2173                        demand,
2174                        not_matched,
2175                        unresolvable,
2176                        count = u64::from(complete),
2177                    );
2178                } else {
2179                    tracing::info!(
2180                        event = name,
2181                        policy_id = %policy,
2182                        demand,
2183                        not_matched,
2184                        unresolvable,
2185                        count = u64::from(complete),
2186                    );
2187                }
2188                // There is deliberately no `warn!` here for the "demand is zero
2189                // but jobs were not matched" shape, though it is the one this
2190                // change introduced: before demand was filtered, a repository
2191                // with work in it always produced some, and now a policy whose
2192                // labels do not cover its jobs produces none.
2193                //
2194                // The reason is that the shape is indistinguishable from a
2195                // healthy one. A repository served by a Windows host and a macOS
2196                // host has the other host's jobs queued in it constantly, so
2197                // each agent would warn on every poll about work that is being
2198                // served correctly by the other machine. Telling the two apart
2199                // needs to know whether this policy has *ever* matched anything,
2200                // which is state across polls that this loop does not keep.
2201                //
2202                // What an operator gets instead is the `not_matched` count, on
2203                // this event and in its `Display`, which `g2` renders. "0 queued
2204                // jobs for this host, 5 for other labels" is the diagnosis; a
2205                // warning that fired on every healthy minute would be the kind
2206                // nobody reads.
2207            }
2208            LifecycleEvent::TargetUnreadable { policy, reason } => {
2209                tracing::warn!(event = name, policy_id = %policy, reason);
2210            }
2211            LifecycleEvent::RoutingRefused {
2212                policy,
2213                reason,
2214                count,
2215            } => tracing::warn!(
2216                event = name,
2217                policy_id = %policy,
2218                reason = reason.as_str(),
2219                count,
2220                "{reason}"
2221            ),
2222            LifecycleEvent::Allocated {
2223                policy,
2224                demand,
2225                desired,
2226                active_owned,
2227                headroom,
2228                to_start,
2229                limiting,
2230            } if demand == 0 && to_start == 0 && active_owned == 0 => tracing::debug!(
2231                // Nothing wanted, nothing held, nothing started: the idle
2232                // steady state, see `DemandObserved` above.
2233                event = name,
2234                policy_id = %policy,
2235                demand,
2236                desired,
2237                capacity = active_owned,
2238                headroom,
2239                count = to_start,
2240                reason = %limiting,
2241            ),
2242            LifecycleEvent::Allocated {
2243                policy,
2244                demand,
2245                desired,
2246                active_owned,
2247                headroom,
2248                to_start,
2249                limiting,
2250            } => tracing::info!(
2251                event = name,
2252                policy_id = %policy,
2253                demand,
2254                desired,
2255                capacity = active_owned,
2256                headroom,
2257                count = to_start,
2258                reason = %limiting,
2259            ),
2260            LifecycleEvent::MonitorOnlySkipped { policy } => {
2261                tracing::debug!(event = name, policy_id = %policy, mode = "monitor_only");
2262            }
2263            LifecycleEvent::RunnerStarted { policy, attempt } => {
2264                tracing::info!(event = name, policy_id = %policy, attempt_id = %attempt);
2265            }
2266            LifecycleEvent::RunnerStartFailed { policy, reason } => {
2267                tracing::warn!(event = name, policy_id = %policy, reason);
2268            }
2269            LifecycleEvent::AllocationDeferred { policy, count } => {
2270                // Contention is normally brief, but a configured WSL recovery
2271                // fence whose shared root is unwritable reaches the same
2272                // fail-closed result forever. Demand was already observed and
2273                // capacity was already granted, so hiding this at DEBUG makes
2274                // a host claim healthy while it starts nothing.
2275                tracing::warn!(
2276                    event = name,
2277                    policy_id = %policy,
2278                    reason = "allocation_lock_unavailable",
2279                    count
2280                );
2281            }
2282            LifecycleEvent::MemoryShort {
2283                policy,
2284                figures,
2285                started,
2286            } => {
2287                let reason = if started {
2288                    "larger_than_free_memory_nothing_else_running"
2289                } else {
2290                    "insufficient_memory"
2291                };
2292                tracing::warn!(
2293                    event = name,
2294                    policy_id = %policy,
2295                    reason,
2296                    free_bytes = figures.free,
2297                    reserved_bytes = figures.reserved,
2298                    estimate_bytes = figures.estimate.bytes,
2299                    estimate_source = figures.estimate.source.as_str(),
2300                    margin_bytes = figures.margin,
2301                );
2302            }
2303            LifecycleEvent::AttemptsUnreadable { reason } => {
2304                tracing::warn!(event = name, reason);
2305            }
2306            LifecycleEvent::HostCapacityUnreadable { reason } => {
2307                tracing::warn!(event = name, reason = reason.reason());
2308            }
2309            LifecycleEvent::AttemptCleaned {
2310                policy,
2311                attempt,
2312                outcome,
2313            } => tracing::info!(
2314                event = name,
2315                policy_id = %policy,
2316                attempt_id = %attempt,
2317                outcome = outcome.as_str(),
2318            ),
2319            LifecycleEvent::AttemptCleanFailed {
2320                policy,
2321                attempt,
2322                reason,
2323            } => tracing::warn!(
2324                event = name,
2325                policy_id = %policy,
2326                attempt_id = %attempt,
2327                reason,
2328            ),
2329            LifecycleEvent::ScaleDownRefused { policy, attempt } => tracing::info!(
2330                event = name,
2331                policy_id = %policy,
2332                attempt_id = %attempt,
2333                attempt_state = "busy",
2334            ),
2335            LifecycleEvent::PollScheduled { retry_in_ms, pace } => {
2336                if pace == PollPace::Idle {
2337                    tracing::debug!(event = name, retry_in_ms, state = pace.as_str());
2338                } else {
2339                    tracing::info!(event = name, retry_in_ms, state = pace.as_str());
2340                }
2341            }
2342        }
2343    }
2344}
2345
2346/// Keeps every event, in order.
2347///
2348/// `g2`'s activity view is a reader of this, and so is every test below.
2349#[derive(Debug, Default)]
2350pub struct EventLog {
2351    events: Mutex<Vec<LifecycleEvent>>,
2352}
2353
2354impl EventLog {
2355    #[must_use]
2356    pub fn new() -> Self {
2357        Self::default()
2358    }
2359
2360    #[must_use]
2361    pub fn events(&self) -> Vec<LifecycleEvent> {
2362        self.events.lock().map(|e| e.clone()).unwrap_or_default()
2363    }
2364
2365    /// How many events of one name were emitted.
2366    #[must_use]
2367    pub fn count_of(&self, name: &str) -> usize {
2368        self.events()
2369            .iter()
2370            .filter(|event| event.name() == name)
2371            .count()
2372    }
2373}
2374
2375impl EventSink for EventLog {
2376    fn emit(&self, event: LifecycleEvent) {
2377        if let Ok(mut events) = self.events.lock() {
2378            events.push(event);
2379        }
2380    }
2381}
2382
2383/// Both sinks at once: the log sink for the operator's file, the buffer for
2384/// `g2`'s screen.
2385#[derive(Debug)]
2386pub struct TeeEvents(pub Arc<dyn EventSink>, pub Arc<dyn EventSink>);
2387
2388impl EventSink for TeeEvents {
2389    fn emit(&self, event: LifecycleEvent) {
2390        self.0.emit(event);
2391        self.1.emit(event);
2392    }
2393}
2394
2395// ---------------------------------------------------------------------------
2396// The reconciler
2397// ---------------------------------------------------------------------------
2398
2399/// Where a policy's demand comes from when the optional coordinator places
2400/// its runners (coordinator stage 3).
2401///
2402/// A profile carrying a shared pool label (`rm-pool-linux`, ...) on a target
2403/// the coordinator covers starts runners only for the launches the
2404/// coordinator placed on this host and the host accepted — never from its own
2405/// reading of the queue, which every host in the pool sees the same way. This
2406/// is a *count*, not a job: a runner still takes whatever matching job GitHub
2407/// hands it, nothing here reserves one, and the number still goes through
2408/// [`HostAllocator`] and every ceiling, exactly as a polled count does.
2409pub trait DemandDirector: fmt::Debug + Send + Sync {
2410    /// `Some(n)`: `policy`'s demand this pass is `n`, not its reading.
2411    /// `None`: its own reading, as without a coordinator.
2412    fn directed_demand(&self, policy: &ScalePolicy) -> Option<u32>;
2413
2414    /// A runner was started for `policy` (as soon as it exists, whether or
2415    /// not its demand was directed).
2416    fn launched(&self, policy: &ScalePolicy, attempt: AttemptId);
2417
2418    /// No runner could be started for `policy`, for a real reason: not a
2419    /// runner still being brought up. `attempt` is the one reported through
2420    /// [`Self::launched`] whose bring-up failed, if it got that far: what it was
2421    /// reported for is waiting again, to be retried rather than placed anew.
2422    fn start_failed(&self, _policy: &ScalePolicy, _attempt: Option<AttemptId>) {}
2423
2424    /// Where `policy`'s oldest launch without a runner stands among this host's
2425    /// launches: lower goes first. `None` when it has none.
2426    fn next_waiting(&self, _policy: &ScalePolicy) -> Option<u64> {
2427        None
2428    }
2429
2430    /// How many launches of other targets of this host are older than
2431    /// `policy`'s oldest without a runner and may yet reserve: `policy`'s
2432    /// launch lets them go first.
2433    fn older_waiting(&self, _policy: &ScalePolicy) -> usize {
2434        0
2435    }
2436
2437    /// Those older launches did not reserve in [`OLDER_LAUNCH_TURN`], so nothing
2438    /// waits for them any more, unless they start waiting afresh.
2439    fn pass_over_older(&self, _policy: &ScalePolicy) {}
2440}
2441
2442/// How long a launch waits for older launches of other targets while none of
2443/// them reserves. Every target's loop reserves as soon as it is told of a launch
2444/// (see [`Reconciler::serve_launches`]), so this is not a turn anyone is expected
2445/// to use up: it is for a loop that cannot reserve at all, a target added a moment
2446/// ago that has no loop yet, or a host-wide lock refused. It starts over whenever
2447/// one of them reserves, and costs one launch that one wait, not one per launch.
2448const OLDER_LAUNCH_TURN: Duration = Duration::from_secs(2);
2449const OLDER_LAUNCH_POLL: Duration = Duration::from_millis(10);
2450
2451/// How soon an accepted launch that did not start is tried again.
2452const LAUNCH_RETRY: Duration = Duration::from_secs(30);
2453
2454/// Where a reservation's wait went, for its launch's timings.
2455#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
2456pub struct ReservationWaits {
2457    /// Letting older launches of this host reserve first.
2458    pub order: Duration,
2459    /// For the allocation lock, and from the reservation to its bring-up.
2460    pub lock: Duration,
2461}
2462
2463/// What one round of serving launches has reserved so far.
2464#[derive(Default)]
2465struct ReservationRound {
2466    /// Per policy, how many it reserved and the most launches seen open: a lease
2467    /// closing during the round must not cut short the launches after it.
2468    reserved: BTreeMap<PolicyId, (u32, u32)>,
2469    exhausted: BTreeSet<PolicyId>,
2470}
2471
2472/// One step of a round: what it reserved, the round, and the policies it read.
2473type Reserving<'a> = std::pin::Pin<
2474    Box<
2475        dyn std::future::Future<Output = (Option<Reserved>, ReservationRound, Vec<ScalePolicy>)>
2476            + Send
2477            + 'a,
2478    >,
2479>;
2480
2481/// A runner reserved under the allocation lock, to bring up outside it.
2482struct Reserved {
2483    policy: ScalePolicy,
2484    attempt: RunnerAttempt,
2485    waits: ReservationWaits,
2486    reserved_at: std::time::Instant,
2487    /// Let go when this runner is up or has failed.
2488    _hold: LaunchHold,
2489}
2490
2491/// Whether the host has the memory for one more runner: asked under the
2492/// allocation lock before every start, after every ceiling granted it, so a
2493/// directed launch and a host's own demand meet the same check. See
2494/// [`runner_manager_domain::memory`].
2495pub trait MemoryGate: fmt::Debug + Send + Sync {
2496    /// Whether one more runner of `policy` may start, given every attempt on
2497    /// the host (those this pass reserved included).
2498    fn admit(&self, policy: &ScalePolicy, attempts: &[RunnerAttempt]) -> MemoryVerdict;
2499
2500    /// `policy` was granted a runner and memory did not stop it: a waiting
2501    /// state recorded for it is over.
2502    fn admitted(&self, _policy: &ScalePolicy) {}
2503}
2504
2505/// No memory admission: every start the ceilings grant goes ahead.
2506#[derive(Debug, Default, Clone, Copy)]
2507pub struct NoMemoryGate;
2508
2509impl MemoryGate for NoMemoryGate {
2510    fn admit(&self, _policy: &ScalePolicy, _attempts: &[RunnerAttempt]) -> MemoryVerdict {
2511        MemoryVerdict::Unchecked
2512    }
2513}
2514
2515/// Every policy's demand is its own reading: no coordinator places runners.
2516#[derive(Debug, Default, Clone, Copy)]
2517pub struct OwnDemand;
2518
2519impl DemandDirector for OwnDemand {
2520    fn directed_demand(&self, _policy: &ScalePolicy) -> Option<u32> {
2521        None
2522    }
2523
2524    fn launched(&self, _policy: &ScalePolicy, _attempt: AttemptId) {}
2525}
2526
2527/// Everything one reconciler needs, written down at the call site.
2528///
2529/// A struct rather than seven positional arguments, for the reason `b1` gives at
2530/// `PersistedAttempt`: several of these are `Arc<dyn …>` and transposing two of
2531/// them type-checks. Construct it with a struct literal so every port is named.
2532#[derive(Clone)]
2533pub struct ReconcilerPorts {
2534    pub demand: Arc<dyn DemandSource>,
2535    pub launcher: Arc<dyn RunnerLauncher>,
2536    pub lock: Arc<dyn AllocationLock>,
2537    pub directory: Arc<dyn RepositoryDirectory>,
2538    pub clock: Arc<dyn Clock>,
2539    pub jitter: Arc<dyn Jitter>,
2540    pub events: Arc<dyn EventSink>,
2541}
2542
2543impl fmt::Debug for ReconcilerPorts {
2544    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2545        f.debug_struct("ReconcilerPorts").finish_non_exhaustive()
2546    }
2547}
2548
2549/// What one reconciliation pass did.
2550///
2551/// `started` and the allocations are reported separately on purpose: an
2552/// allocation is what the pass *decided* under the lock, and `started` is what
2553/// actually came up. They differ when a launch fails or when the lock was held,
2554/// and collapsing them would hide both.
2555#[derive(Debug, Clone, Default)]
2556pub struct ReconcileReport {
2557    /// One entry per policy that got as far as being allocated for.
2558    pub allocations: Vec<Allocation>,
2559    /// Policies skipped because they are monitor-only (D19).
2560    pub monitor_only: Vec<PolicyId>,
2561    /// Policies whose target could not be polled this pass.
2562    pub unreadable: Vec<PolicyId>,
2563    /// Policies whose target GitHub actually answered for this pass.
2564    ///
2565    /// The counterpart to [`Self::unreadable`], and the only honest evidence
2566    /// that this host reached GitHub at all. [`Self::allocations`] is not: a
2567    /// policy this host does not own is allocated for with no demand and
2568    /// without any target being polled, so a pass where every poll failed can
2569    /// still end with allocations in it.
2570    pub targets_read: u16,
2571    /// Runners actually started.
2572    pub started: u16,
2573    /// Pre-acceptance attempts routed back through this pass's ordinary
2574    /// demand/capacity decision.
2575    pub replacement_intents: u16,
2576    /// Terminal attempts whose runtime was removed.
2577    pub cleaned: u16,
2578    /// Of those, the surplus case: registered, got no job, exited on its idle
2579    /// timeout. **Not** a failure.
2580    pub idle_exits: u16,
2581    /// Of those, the ones an operator should look at.
2582    pub failures: u16,
2583    /// Runners this pass was granted but did not start because the allocation
2584    /// lock was held.
2585    ///
2586    /// **Grants, not policies.** It used to be incremented once per
2587    /// `start_runners` call that met a held lock, so a policy that launched two
2588    /// of five and then lost the lock reported `1` while three runners went
2589    /// unstarted -- a number that agreed with neither its own name nor its
2590    /// documentation.
2591    pub deferred: u16,
2592    /// Runners this pass was granted but did not start because the host
2593    /// lacked the memory for them. Their demand stays queued.
2594    pub memory_deferred: u16,
2595    /// Times the host's attempt set could not be read this pass.
2596    ///
2597    /// Non-zero means the pass decided less than it looks like it decided: a
2598    /// policy whose attempt set was unreadable started nothing and is *not* in
2599    /// [`Self::allocations`], because there was no set to compute an allocation
2600    /// from. It is not the same as the host being idle, which is the whole
2601    /// reason [`RunnerLauncher::attempts`] is fallible.
2602    ///
2603    /// **A count, where [`Self::unreadable`] is a `Vec<PolicyId>`, and that
2604    /// asymmetry is deliberate.** An unreadable *target* is a fact about one
2605    /// policy's GitHub target; an unreadable *attempt set* is a fact about this
2606    /// host's journal, which no policy owns — two of the three paths that reach
2607    /// it (`clean_terminal_attempts` and `scale_down`) have no policy in hand at
2608    /// all. Naming policies here would mean either inventing an owner for a
2609    /// host-wide failure or reporting a partial list, and both read as more
2610    /// precision than there is. The pass is distinguishable from an idle one,
2611    /// which is what the field exists for; the per-policy attribution is not
2612    /// available, and is recorded as missing rather than faked.
2613    pub attempts_unreadable: u16,
2614    /// Allocation checks blocked by a missing or unreadable host ceiling.
2615    pub host_capacity_unreadable: u16,
2616    /// Terminal attempts whose runtime could not be removed. Retried next pass.
2617    pub clean_failures: u16,
2618    /// The most severe failure across the targets polled, when there was one.
2619    pub failure: Option<RefreshState>,
2620    /// What to display while GitHub is unreachable, including how long the
2621    /// outage has run and therefore whether queued work has already been lost.
2622    pub offline: Option<OfflineState>,
2623    /// When to poll next, and why then.
2624    pub next_poll: NextPoll,
2625    /// Demand requests this pass projected against the shared hourly ceiling.
2626    pub demand_requests: u32,
2627}
2628
2629impl ReconcileReport {
2630    /// Whether this pass actually reached GitHub, which is the only thing that
2631    /// entitles it to write a `last GitHub contact`.
2632    ///
2633    /// # Positive evidence, because the absence of a failure is not evidence
2634    ///
2635    /// The record used to be written whenever [`Self::failure`] was `None`, on
2636    /// the belief that an unauthorized target lands in [`Self::unreadable`]
2637    /// rather than in `failure`. **That belief is wrong.** `unreadable` is
2638    /// pushed only from the `PollOutcome::Failed` arm, `failure` is the maximum
2639    /// over every `Failed` reading, and `RefreshState::Unauthorized` scores 2 —
2640    /// so a non-empty `unreadable` always implies `failure.is_some()`, and
2641    /// guarding on both would have changed nothing at all.
2642    ///
2643    /// The path that really writes a contact record without touching GitHub is
2644    /// a pass that polls **nothing**: every policy draining, owned by another
2645    /// host, or monitor-only. `pollable` is then empty, no reading exists, no
2646    /// failure is computed, and the old guard passed. That is how
2647    /// `service status` can answer `healthy` on a host doing nothing at all.
2648    ///
2649    /// So this asks for evidence rather than for the absence of a complaint. A
2650    /// pass with nothing to ask reaches nobody and records nothing, which is
2651    /// what `never` in `service status` is for.
2652    ///
2653    /// Conservative on purpose: `repositories.scope_for` is a real request that
2654    /// can succeed before a demand poll fails, and it is not counted. Contact
2655    /// that cannot be proven is not claimed.
2656    #[must_use]
2657    pub const fn reached_github(&self) -> bool {
2658        self.targets_read > 0
2659    }
2660}
2661
2662impl Default for NextPoll {
2663    fn default() -> Self {
2664        Self {
2665            delay: PollSchedule::floor(),
2666            pace: PollPace::Nominal,
2667        }
2668    }
2669}
2670
2671impl ReconcileReport {
2672    /// Whether GitHub was unreachable this pass.
2673    #[must_use]
2674    pub fn is_offline(&self) -> bool {
2675        matches!(self.failure, Some(RefreshState::Offline))
2676    }
2677
2678    /// The offline state to display, when this pass was one.
2679    #[must_use]
2680    pub const fn offline_state(&self) -> Option<&OfflineState> {
2681        self.offline.as_ref()
2682    }
2683
2684    /// Attempts this pass created. The idle-host assertion reads this.
2685    #[must_use]
2686    pub const fn starts_nothing(&self) -> bool {
2687        self.started == 0
2688    }
2689}
2690
2691/// What one scale-down request did.
2692#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
2693pub struct ScaleDownReport {
2694    /// Terminal attempts whose runtime was removed.
2695    pub removed: u16,
2696    /// Attempts executing a job. **Removed nothing, left `busy`.**
2697    pub refused_busy: u16,
2698    /// Terminal attempts whose runtime could not be removed.
2699    pub clean_failures: u16,
2700    /// Live attempts that are not yet busy. Also removed nothing: capacity is
2701    /// reclaimed only when an attempt reaches a terminal state.
2702    pub retained: u16,
2703    /// The host's attempt set could not be read, so **every other field here is
2704    /// meaningless** rather than zero.
2705    ///
2706    /// This is the same distinction [`ReconcileReport::attempts_unreadable`]
2707    /// draws, and it is here for the same reason: a default
2708    /// [`ScaleDownReport`] and a scale-down that could not see the machine are
2709    /// both all-zeros, and they mean opposite things — "there was nothing to
2710    /// reclaim" against "we do not know what there was". Check
2711    /// [`Self::is_conclusive`] before reading a zero as an answer.
2712    pub attempts_unreadable: bool,
2713}
2714
2715impl ScaleDownReport {
2716    /// Whether the counts here describe the machine at all.
2717    ///
2718    /// `false` means the attempt set could not be read, so every zero is
2719    /// "unknown" rather than "none".
2720    #[must_use]
2721    pub const fn is_conclusive(&self) -> bool {
2722        !self.attempts_unreadable
2723    }
2724}
2725
2726/// How long a failed cleanup of one attempt waits before it is tried again,
2727/// doubling per consecutive failure up to [`CLEAN_RETRY_MAXIMUM`].
2728///
2729/// A runtime that cannot be removed used to be retried on every pass, about
2730/// every six seconds, and each failure logs a warning: one stuck Windows
2731/// workspace wrote some 15,000 of them a day. The retry is still real; it is
2732/// just no longer a log flood.
2733const CLEAN_RETRY_INITIAL: Duration = Duration::from_secs(30);
2734
2735/// The longest a failing cleanup waits between tries.
2736const CLEAN_RETRY_MAXIMUM: Duration = Duration::from_secs(30 * 60);
2737
2738/// One attempt's consecutive cleanup failures, and when it may be tried again.
2739#[derive(Debug, Clone, Copy)]
2740struct CleanRetry {
2741    failures: u32,
2742    not_before: Timestamp,
2743}
2744
2745/// Per-attempt cleanup backoff. One each in the [`Reconciler`]'s sweep of
2746/// concluded attempts and in the lifecycle launcher's own supervision pass,
2747/// the two places a failed cleanup is retried.
2748#[derive(Debug, Default)]
2749pub(crate) struct CleanBackoff {
2750    retries: BTreeMap<AttemptId, CleanRetry>,
2751}
2752
2753impl CleanBackoff {
2754    /// The doubling-with-a-ceiling schedule; the attempt bound is unused.
2755    const SCHEDULE: crate::lifecycle::RetryPolicy =
2756        crate::lifecycle::RetryPolicy::bounded(u32::MAX, CLEAN_RETRY_INITIAL, CLEAN_RETRY_MAXIMUM);
2757
2758    /// Whether `attempt`'s cleanup should be tried at `now`.
2759    pub(crate) fn is_due(&self, attempt: AttemptId, now: Timestamp) -> bool {
2760        self.retries
2761            .get(&attempt)
2762            .is_none_or(|retry| now >= retry.not_before)
2763    }
2764
2765    /// Records a failed cleanup and schedules the next try.
2766    pub(crate) fn failed(&mut self, attempt: AttemptId, now: Timestamp) {
2767        let failures = self
2768            .retries
2769            .get(&attempt)
2770            .map_or(1, |retry| retry.failures.saturating_add(1));
2771        let delay = Self::SCHEDULE.delay(failures);
2772        self.retries.insert(
2773            attempt,
2774            CleanRetry {
2775                failures,
2776                // At most `CLEAN_RETRY_MAXIMUM`, so always representable.
2777                not_before: now + chrono::Duration::from_std(delay).unwrap_or_default(),
2778            },
2779        );
2780    }
2781
2782    /// Forgets `attempt`: it was cleaned.
2783    pub(crate) fn succeeded(&mut self, attempt: AttemptId) {
2784        self.retries.remove(&attempt);
2785    }
2786
2787    /// Forgets every attempt not in `present`, so the map cannot outgrow the
2788    /// journal.
2789    fn retain(&mut self, present: &BTreeSet<AttemptId>) {
2790        self.retries.retain(|attempt, _| present.contains(attempt));
2791    }
2792}
2793
2794/// The reconciliation loop.
2795///
2796/// One per target, as `f3` runs them; they share a [`RunnerLauncher`] and an
2797/// [`AllocationLock`], which is what keeps the host ceiling true across all of
2798/// them.
2799#[derive(Debug)]
2800pub struct Reconciler {
2801    host: Host,
2802    demand: Arc<dyn DemandSource>,
2803    launcher: Arc<dyn RunnerLauncher>,
2804    lock: Arc<dyn AllocationLock>,
2805    repositories: RepositoryCache,
2806    clock: Arc<dyn Clock>,
2807    jitter: Arc<dyn Jitter>,
2808    events: Arc<dyn EventSink>,
2809    schedule: PollSchedule,
2810    clean_backoff: CleanBackoff,
2811    director: Arc<dyn DemandDirector>,
2812    /// What serves this target's launches beside its passes, when something
2813    /// does: see [`Self::with_launch_side`].
2814    launch_side: Option<Arc<tokio::sync::Notify>>,
2815    memory: Arc<dyn MemoryGate>,
2816}
2817
2818impl Reconciler {
2819    /// Build a reconciler polling at the host's configured interval.
2820    #[must_use]
2821    pub fn new(host: Host, ports: ReconcilerPorts) -> Self {
2822        let interval = host.refresh_interval;
2823        let repositories = RepositoryCache::new(
2824            Arc::clone(&ports.directory),
2825            Arc::clone(&ports.clock),
2826            interval,
2827        );
2828        Self {
2829            host,
2830            demand: ports.demand,
2831            launcher: ports.launcher,
2832            lock: ports.lock,
2833            repositories,
2834            clock: ports.clock,
2835            jitter: ports.jitter,
2836            events: ports.events,
2837            schedule: PollSchedule::new(interval),
2838            clean_backoff: CleanBackoff::default(),
2839            director: Arc::new(OwnDemand),
2840            launch_side: None,
2841            memory: Arc::new(NoMemoryGate),
2842        }
2843    }
2844
2845    /// Checks every start against the host's memory. See [`MemoryGate`].
2846    #[must_use]
2847    pub fn with_memory_gate(mut self, memory: Arc<dyn MemoryGate>) -> Self {
2848        self.memory = memory;
2849        self
2850    }
2851
2852    /// Leaves this reconciler's directed launches to whatever `signal` wakes
2853    /// ([`Self::serve_launches`]). A pass then only reports on them. A pass that
2854    /// brought one up itself would be held for as long as its package takes, and
2855    /// a pass is what concludes a runner that exited and frees its slot.
2856    #[must_use]
2857    pub fn with_launch_side(mut self, signal: Arc<tokio::sync::Notify>) -> Self {
2858        self.launch_side = Some(signal);
2859        self
2860    }
2861
2862    /// Lets `director` replace a policy's polled demand with the launches the
2863    /// coordinator placed here. See [`DemandDirector`].
2864    #[must_use]
2865    pub fn with_director(mut self, director: Arc<dyn DemandDirector>) -> Self {
2866        self.director = director;
2867        self
2868    }
2869
2870    #[must_use]
2871    pub const fn host(&self) -> &Host {
2872        &self.host
2873    }
2874
2875    #[must_use]
2876    pub const fn schedule(&self) -> &PollSchedule {
2877        &self.schedule
2878    }
2879
2880    /// Adopt intervals an operator changed while the loop runs.
2881    pub fn set_intervals(&mut self, active: RefreshInterval, idle: Duration) {
2882        self.host.refresh_interval = active;
2883        self.schedule.set_intervals(active, idle);
2884    }
2885
2886    /// The repository-list cache, so `f1` can report what it has spent.
2887    #[must_use]
2888    pub const fn repositories(&self) -> &RepositoryCache {
2889        &self.repositories
2890    }
2891
2892    /// Starts the runners of `policies`' coordinator launches, and nothing
2893    /// else: no GitHub poll, no supervision. Every launch open at that moment is
2894    /// served by one call. See [`Self::serve_launches`].
2895    #[cfg(test)]
2896    pub async fn start_directed(&self, policies: &[ScalePolicy]) -> ReconcileReport {
2897        let mut report = ReconcileReport::default();
2898        let mut round = ReservationRound::default();
2899        let mut reserved = Vec::new();
2900        while let Some(next) = self
2901            .reserve_next_launch(policies, &mut round, &mut report)
2902            .await
2903        {
2904            reserved.push(next);
2905        }
2906        self.finish_reserved(reserved, &mut report).await;
2907        report
2908    }
2909
2910    /// Serves a target's coordinator launches until `stop`: on `signal`, and
2911    /// [`LAUNCH_RETRY`] after one that did not start, it reserves a runner for
2912    /// every launch open at that moment, one at a time, and brings each up as soon
2913    /// as it is reserved. A launch the coordinator placed here needs no reading of
2914    /// the queue, so this runs beside the target's passes: a launch accepted while
2915    /// one is polling GitHub, or while runners already reserved are coming up, is
2916    /// reserved and brought up at once rather than after them.
2917    ///
2918    /// It goes through the same allocator, ceilings and host-wide lock as
2919    /// [`Self::reconcile`]. Neither needs to know of the other's runners: each
2920    /// reads the journal under the lock, which holds every reservation. What it
2921    /// reserves is the launcher's to keep apart from supervision while it comes
2922    /// up (`LifecycleLauncher::supervise`), and the next full pass supervises it.
2923    /// After `stop` it reserves nothing more and returns once what it reserved is
2924    /// up or has failed.
2925    pub async fn serve_launches(
2926        &self,
2927        policies: &(dyn Fn() -> Vec<ScalePolicy> + Send + Sync),
2928        signal: &tokio::sync::Notify,
2929        mut stop: tokio::sync::watch::Receiver<bool>,
2930    ) {
2931        use futures::StreamExt as _;
2932
2933        let mut bringing_up = futures::stream::FuturesUnordered::new();
2934        let mut reserving: Option<Reserving<'_>> = None;
2935        let mut retry_at: Option<tokio::time::Instant> = None;
2936        let mut known: Vec<ScalePolicy> = Vec::new();
2937        let mut stopping = *stop.borrow();
2938        loop {
2939            if stopping && reserving.is_none() && bringing_up.is_empty() {
2940                return;
2941            }
2942            tokio::select! {
2943                () = signal.notified(), if !stopping && reserving.is_none() => {
2944                    reserving = Some(self.reserve_step(policies(), ReservationRound::default()));
2945                }
2946                () = async {
2947                    match retry_at {
2948                        Some(at) => tokio::time::sleep_until(at).await,
2949                        None => std::future::pending().await,
2950                    }
2951                }, if !stopping && reserving.is_none() => {
2952                    retry_at = None;
2953                    reserving = Some(self.reserve_step(policies(), ReservationRound::default()));
2954                }
2955                (reserved, round, read) = async {
2956                    match reserving.as_mut() {
2957                        Some(reserving) => reserving.await,
2958                        None => std::future::pending().await,
2959                    }
2960                } => {
2961                    reserving = None;
2962                    match reserved {
2963                        Some(reserved) => {
2964                            bringing_up.push(self.bring_up(reserved));
2965                            known = read;
2966                            if !stopping {
2967                                reserving = Some(self.reserve_step(policies(), round));
2968                            }
2969                        }
2970                        None => known = read,
2971                    }
2972                }
2973                Some(_) = bringing_up.next(), if !bringing_up.is_empty() => {}
2974                changed = stop.changed(), if !stopping => {
2975                    stopping = changed.is_err() || *stop.borrow();
2976                }
2977            }
2978            // One that did not start, a registration that failed, is tried again
2979            // well inside its lease's lifetime.
2980            if reserving.is_none()
2981                && retry_at.is_none()
2982                && known
2983                    .iter()
2984                    .any(|policy| self.director.next_waiting(policy).is_some())
2985            {
2986                retry_at = Some(tokio::time::Instant::now() + LAUNCH_RETRY);
2987            }
2988        }
2989    }
2990
2991    /// [`Self::reserve_next_launch`], as a future that owns what it needs.
2992    fn reserve_step(
2993        &self,
2994        policies: Vec<ScalePolicy>,
2995        mut round: ReservationRound,
2996    ) -> Reserving<'_> {
2997        Box::pin(async move {
2998            let mut report = ReconcileReport::default();
2999            let reserved = self
3000                .reserve_next_launch(&policies, &mut round, &mut report)
3001                .await;
3002            (reserved, round, policies)
3003        })
3004    }
3005
3006    /// Reserves one runner for the oldest of `policies`' open launches: a target's
3007    /// own profiles keep the order too. Slow work comes after this, in
3008    /// [`Self::bring_up`], so no launch's package holds up another's reservation.
3009    /// `None` when `round` has served all it can.
3010    async fn reserve_next_launch(
3011        &self,
3012        policies: &[ScalePolicy],
3013        round: &mut ReservationRound,
3014        report: &mut ReconcileReport,
3015    ) -> Option<Reserved> {
3016        loop {
3017            let policy = policies
3018                .iter()
3019                .filter(|policy| {
3020                    policy.owns_runners()
3021                        && policy.is_owned_by(self.host.id)
3022                        && policy.may_start_runners()
3023                        && !round.exhausted.contains(&policy.id)
3024                })
3025                .min_by_key(|policy| self.director.next_waiting(policy).unwrap_or(u64::MAX))?;
3026            let (served, most) = round.reserved.entry(policy.id).or_default();
3027            // Bounded by the launches open, however late the journal shows these.
3028            if let Some(directed) = self.director.directed_demand(policy)
3029                && *served < directed.max(*most)
3030            {
3031                *most = directed.max(*most);
3032                let mut reserved = Vec::new();
3033                self.start_runners(policy, directed, 1, report, &mut reserved)
3034                    .await;
3035                if let Some(next) = reserved.pop() {
3036                    *served += 1;
3037                    return Some(next);
3038                }
3039            }
3040            round.exhausted.insert(policy.id);
3041        }
3042    }
3043
3044    /// One reconciliation pass over `policies`.
3045    ///
3046    /// The order of operations is `03-control-flows.md` flow 2, and the two
3047    /// steps most worth naming are the ones that are silent when they are wrong:
3048    ///
3049    /// * **Monitor-only policies are removed before the demand poll**, not
3050    ///   after. D19 says such a policy "is skipped entirely by reconciliation",
3051    ///   and a poll issued on its behalf would spend requests from the shared
3052    ///   ceiling for a policy that can never act on the answer. This is asserted
3053    ///   on [`ScalePolicy::owns_runners`] rather than deduced from
3054    ///   `max_capacity` being absent.
3055    /// * **The attempt set is re-read under the lock, once per runtime.** See
3056    ///   [`RunnerLauncher`] for why it comes from there and nowhere else.
3057    pub async fn reconcile(&mut self, policies: &[ScalePolicy]) -> ReconcileReport {
3058        let mut report = ReconcileReport::default();
3059        // Everything this pass has created, carried across policies so that the
3060        // host-wide total cannot be computed from a set that is missing it. See
3061        // `RunnerLauncher::launch`.
3062        let mut reserved: Vec<Reserved> = Vec::new();
3063
3064        // --- Flow 2.1-2.2: who is even asking, and what did GitHub say -------
3065        let mut pollable: Vec<&ScalePolicy> = Vec::new();
3066        let mut supervision_failed = BTreeSet::new();
3067        for policy in policies {
3068            if !policy.owns_runners() {
3069                report.monitor_only.push(policy.id);
3070                self.events
3071                    .emit(LifecycleEvent::MonitorOnlySkipped { policy: policy.id });
3072                continue;
3073            }
3074            if !policy.is_owned_by(self.host.id) {
3075                // Ownership rule 2 and precedence rule 4. The allocator reports
3076                // both by name below; polling on their behalf would spend
3077                // requests for an answer that cannot be acted on.
3078                continue;
3079            }
3080            match self.launcher.supervise(policy).await {
3081                Ok(intents) => {
3082                    report.replacement_intents = report
3083                        .replacement_intents
3084                        .saturating_add(u16::try_from(intents.len()).unwrap_or(u16::MAX));
3085                }
3086                Err(failure) => {
3087                    supervision_failed.insert(policy.id);
3088                    self.report_unreadable_attempts(&mut report, &failure);
3089                    continue;
3090                }
3091            }
3092            if !policy.may_start_runners() {
3093                continue;
3094            }
3095            pollable.push(policy);
3096        }
3097
3098        let readings = self.poll_targets(&pollable, &mut report).await;
3099        report.targets_read = u16::try_from(
3100            readings
3101                .values()
3102                .filter(|outcome| matches!(outcome, PollOutcome::Ready(_)))
3103                .count(),
3104        )
3105        .unwrap_or(u16::MAX);
3106        let mut routed = BTreeMap::new();
3107        for (target, outcome) in &readings {
3108            let PollOutcome::Ready(reading) = outcome else {
3109                continue;
3110            };
3111            let profiles: Vec<&ScalePolicy> = pollable
3112                .iter()
3113                .copied()
3114                .filter(|policy| &policy.target == target)
3115                .collect();
3116            let (tallies, refusals) = route_demand(policies, &profiles, reading);
3117            if let Some(first) = profiles.first() {
3118                for (reason, count) in refusals {
3119                    self.events.emit(LifecycleEvent::RoutingRefused {
3120                        policy: first.id,
3121                        reason,
3122                        count,
3123                    });
3124                }
3125            }
3126            routed.insert(target.clone(), tallies);
3127        }
3128
3129        // --- Flow 2.8: terminal attempts, whatever else this pass does -------
3130        //
3131        // Run before the allocation phase so that a report's `cleaned` count
3132        // describes the same instant its allocations do. It does not change the
3133        // arithmetic: a terminal attempt already stopped counting against
3134        // capacity when it became terminal, which is `b1`'s
3135        // `counts_against_capacity`. It touches no live process, so it is also
3136        // safe during an outage — flow 3.3 requires that running runners be
3137        // retained, and nothing here can reach one.
3138        self.clean_terminal_attempts(&mut report).await;
3139
3140        // --- Flow 2.3-2.6: the allocation -----------------------------------
3141        //
3142        // The predicates are re-tested here rather than the reading being looked
3143        // up by target, and that is not redundancy. **Targets are shared.** A
3144        // monitor-only policy watching `acme/app` alongside an autoscale policy
3145        // on the *same* repository finds a reading in the map that the other
3146        // policy paid for, and a lookup-driven loop then serves it: it emits a
3147        // demand observation on its behalf and clamps a number it has no
3148        // business seeing.
3149        //
3150        // Nothing downstream goes wrong when that happens — `may_start_runners`
3151        // is false for a monitor-only policy, so `HostAllocator` refuses it and
3152        // `to_start` is zero. It simply is not *skipped*, and D19's word is
3153        // "entirely".
3154        for policy in policies {
3155            if !policy.owns_runners() {
3156                // Already recorded and reported above, before any demand request
3157                // was issued. It owns no routing labels, takes no part in
3158                // demand, and can never be the reason a runner starts. Asserted
3159                // on the mode rather than deduced from `max_capacity` being
3160                // absent, which is what the specification requires.
3161                continue;
3162            }
3163            if !policy.is_owned_by(self.host.id) || !policy.may_start_runners() {
3164                // Ownership rule 2 and precedence rule 4. Allocated for with no
3165                // demand, so the refusal is reported by name rather than by
3166                // absence.
3167                match self.allocate_only(policy, 0, &reserved).await {
3168                    Ok(allocation) => {
3169                        self.emit_allocation(&allocation);
3170                        report.allocations.push(allocation);
3171                    }
3172                    Err(failure) => self.report_allocation_failure(&mut report, &failure),
3173                }
3174                continue;
3175            }
3176            if supervision_failed.contains(&policy.id) {
3177                continue;
3178            }
3179            // Coordinated placement: the launches the coordinator placed
3180            // here are the demand, whatever this host's own reading says (or
3181            // whether it could read at all).
3182            if let Some(directed) = self.director.directed_demand(policy) {
3183                let Some(launch_side) = &self.launch_side else {
3184                    self.start_runners(policy, directed, u16::MAX, &mut report, &mut reserved)
3185                        .await;
3186                    continue;
3187                };
3188                match self.allocate_only(policy, directed, &reserved).await {
3189                    Ok(allocation) => {
3190                        if !allocation.starts_nothing() {
3191                            launch_side.notify_one();
3192                        }
3193                        report.allocations.push(allocation);
3194                    }
3195                    Err(failure) => self.report_allocation_failure(&mut report, &failure),
3196                }
3197                continue;
3198            }
3199            let Some(reading) = readings.get(&policy.target) else {
3200                // Unreachable: every policy reaching here was in `pollable`, and
3201                // `poll_targets` inserts an outcome for each of their targets.
3202                debug_assert!(false, "a pollable policy's target has no reading");
3203                continue;
3204            };
3205            match reading {
3206                PollOutcome::Failed(state) => {
3207                    report.unreadable.push(policy.id);
3208                    self.events.emit(LifecycleEvent::TargetUnreadable {
3209                        policy: policy.id,
3210                        reason: unreadable_reason(state),
3211                    });
3212                }
3213                PollOutcome::Ready(demand) => {
3214                    let tally = routed
3215                        .get(&policy.target)
3216                        .and_then(|tallies| tallies.get(&policy.id))
3217                        .expect("every pollable profile has a routed tally");
3218                    let count = tally.demand();
3219                    self.events.emit(LifecycleEvent::DemandObserved {
3220                        policy: policy.id,
3221                        demand: count,
3222                        not_matched: tally.not_matched,
3223                        unresolvable: u32::try_from(tally.unresolvable.len()).unwrap_or(u32::MAX),
3224                        complete: demand.is_complete(),
3225                    });
3226                    self.start_runners(policy, count, u16::MAX, &mut report, &mut reserved)
3227                        .await;
3228                }
3229            }
3230        }
3231        self.finish_reserved(reserved, &mut report).await;
3232
3233        // --- Flow 2.1 / 3.3: when to come back ------------------------------
3234        let failure = readings
3235            .values()
3236            .filter_map(PollOutcome::failure)
3237            .max_by_key(|state| severity(state))
3238            .cloned();
3239        let now = self.clock.now();
3240        let conditions = PollConditions {
3241            quiet: is_quiet(&readings, &report),
3242            requests_per_pass: report.demand_requests,
3243            quota: readings
3244                .values()
3245                .filter_map(PollOutcome::reading)
3246                .filter_map(QueuedDemand::rate_limit)
3247                .max_by_key(|quota| quota.observed_at)
3248                .copied(),
3249        };
3250        report.next_poll =
3251            self.schedule
3252                .next_poll_with(failure.as_ref(), conditions, now, self.jitter.as_ref());
3253        report.failure = failure;
3254        // Flow 3.3's fourth obligation: the offline state carries the 24-hour
3255        // bound, and it can only say whether that bound has passed if it is
3256        // given the real elapsed time rather than an estimate from the interval.
3257        if let PollPace::Offline { consecutive } = report.next_poll.pace {
3258            let state = OfflineState::new(consecutive, report.next_poll.delay);
3259            report.offline = Some(match self.schedule.offline_for(now) {
3260                Some(elapsed) => state.since(elapsed),
3261                None => state,
3262            });
3263        }
3264        self.events.emit(LifecycleEvent::PollScheduled {
3265            retry_in_ms: u64::try_from(report.next_poll.delay.as_millis()).unwrap_or(u64::MAX),
3266            pace: report.next_poll.pace,
3267        });
3268
3269        report
3270    }
3271
3272    /// Poll each distinct target once, however many policies share it.
3273    ///
3274    /// Two policies on one repository are one demand request, not two. That is
3275    /// not a micro-optimisation: the budget model in
3276    /// `04-subsystem-contracts.md` prices a *target*, and a loop that spent per
3277    /// policy would quietly exceed the projection `f2` admitted the
3278    /// configuration against.
3279    async fn poll_targets(
3280        &self,
3281        pollable: &[&ScalePolicy],
3282        report: &mut ReconcileReport,
3283    ) -> BTreeMap<ScaleTarget, PollOutcome> {
3284        let targets: BTreeSet<ScaleTarget> = pollable.iter().map(|p| p.target.clone()).collect();
3285
3286        let mut readings = BTreeMap::new();
3287        for target in targets {
3288            let outcome = match self.repositories.scope_for(&target).await {
3289                Ok(scope) => {
3290                    report.demand_requests = report
3291                        .demand_requests
3292                        .saturating_add(demand_requests_per_poll(&scope));
3293                    self.demand.poll(&scope).await
3294                }
3295                // The repository list could not be refreshed, so the scope of
3296                // the poll is unknown. Polling a stale or empty scope would
3297                // report a demand number for a set of repositories nobody
3298                // chose, which is worse than reporting that the target could
3299                // not be read.
3300                Err(error) => PollOutcome::Failed(RefreshState::from_error(&error)),
3301            };
3302            readings.insert(target, outcome);
3303        }
3304        readings
3305    }
3306
3307    /// Compute one policy's allocation without creating anything.
3308    ///
3309    /// # Why this is safe without the lock
3310    ///
3311    /// **Not** because it cannot grant — it can, and
3312    /// [`Reconciler::start_runners`] uses it as a pre-check precisely for the
3313    /// number it returns. That was the original reason and this function
3314    /// outgrew it; the reason now is that it *decides* nothing. Nothing is
3315    /// created here, the headroom it read is re-read under the lock before any
3316    /// runtime exists, and the under-lock allocation may only lower what this
3317    /// one proposed. So there is no read-decide-create sequence here to make
3318    /// atomic, and the worst this can be is optimistic — which the lock then
3319    /// corrects.
3320    ///
3321    /// # Errors
3322    /// A host-ceiling or attempt-journal read failure. Neither permits an
3323    /// allocation, and they remain distinct in diagnostics.
3324    async fn allocate_only(
3325        &self,
3326        policy: &ScalePolicy,
3327        demand: u32,
3328        reserved: &[Reserved],
3329    ) -> Result<Allocation, AllocationFailure> {
3330        self.allocate_over(policy, demand, reserved)
3331            .await
3332            .map(|(allocation, _)| allocation)
3333    }
3334
3335    /// [`Self::allocate_only`], and the attempt set it was decided over.
3336    async fn allocate_over(
3337        &self,
3338        policy: &ScalePolicy,
3339        demand: u32,
3340        reserved: &[Reserved],
3341    ) -> Result<(Allocation, Vec<RunnerAttempt>), AllocationFailure> {
3342        // Read again under the shared launch lock, not just at each target's
3343        // poll boundary: another target must not use an old, higher ceiling.
3344        let mut host = self.host.clone();
3345        host.host_capacity = self
3346            .launcher
3347            .host_capacity(&self.host)
3348            .await
3349            .map_err(AllocationFailure::HostCapacity)?;
3350        let attempts = self
3351            .host_attempts(reserved)
3352            .await
3353            .map_err(AllocationFailure::Attempts)?;
3354        let mut allocator = HostAllocator::from_attempts(&host, &attempts);
3355        let allocation = allocator.allocate(policy, demand);
3356        Ok((allocation, attempts))
3357    }
3358
3359    /// Flow 2.4-2.6: start runners for one policy, one lock hold per runtime.
3360    ///
3361    /// # Two stopping conditions, and both are needed
3362    ///
3363    /// The loop re-reads the attempt set under every hold, so the obvious stop
3364    /// is "the allocator granted nothing". That condition **alone does not
3365    /// terminate**, and the failure is not hypothetical — it was measured.
3366    /// Handing the allocator a set that does not include the runners this loop
3367    /// just started (an empty one, a stale one, or a launcher whose journal
3368    /// write has not landed yet) makes every grant look like the first, and the
3369    /// pass starts runners until something outside it intervenes. With the set
3370    /// dropped entirely, the three-consecutive-polls test below does not report
3371    /// three attempts; it *never returns*.
3372    ///
3373    /// So the grant decided on the first hold is also a **budget**. A later hold
3374    /// may lower it — the host may have filled up meanwhile — and can never
3375    /// raise it, which bounds the pass at the number this policy was actually
3376    /// allocated. That is `c2`'s reasoning for `MAX_PAGES` one layer down: the
3377    /// reconciliation loop is the one place in this product that must not be
3378    /// able to wedge, so the bound is structural rather than a consequence of
3379    /// every input being well behaved.
3380    ///
3381    /// At most `limit` runners: one, when the caller decides who is next.
3382    async fn start_runners(
3383        &self,
3384        policy: &ScalePolicy,
3385        demand: u32,
3386        limit: u16,
3387        report: &mut ReconcileReport,
3388        reserved: &mut Vec<Reserved>,
3389    ) {
3390        // A lock-free pre-check, for one reason only: the host-wide lock should
3391        // not be taken by a policy that is going to be granted nothing. On an
3392        // idle host with P policies that was P lock acquisitions per poll --
3393        // free under `InProcessAllocationLock`, a `spawn_blocking` and a
3394        // filesystem lock apiece under `FileAllocationLock`.
3395        //
3396        // It is safe because it can only be optimistic. Anything it grants is
3397        // re-decided under the lock below and may be lowered there; the only
3398        // thing it can get wrong in the other direction is refusing a grant that
3399        // headroom freed a moment later would have allowed, which the next poll
3400        // picks up.
3401        let intent = match self.allocate_only(policy, demand, reserved).await {
3402            Ok(intent) => intent,
3403            Err(failure) => {
3404                self.report_allocation_failure(report, &failure);
3405                self.director.start_failed(policy, None);
3406                return;
3407            }
3408        };
3409
3410        // The allocation that is *reported* is the one taken under the lock when
3411        // a lock was taken, because that is the one that decided anything. The
3412        // pre-check stands in only when no hold was ever obtained.
3413        let mut decided: Option<Allocation> = None;
3414        let mut budget = intent.to_start.min(limit);
3415
3416        while budget > 0 {
3417            let order_wait = self.let_older_launches_reserve(policy).await;
3418            // A launch may have been cancelled while this one waited.
3419            let demand = if order_wait.is_zero() {
3420                demand
3421            } else {
3422                self.director.directed_demand(policy).unwrap_or(demand)
3423            };
3424            let waiting_since = std::time::Instant::now();
3425            let guard = match self.lock.acquire().await {
3426                Ok(guard) => guard,
3427                Err(_) => {
3428                    // Grants, not policies: this is what the policy was owed and
3429                    // did not get.
3430                    report.deferred = report.deferred.saturating_add(budget);
3431                    self.events.emit(LifecycleEvent::AllocationDeferred {
3432                        policy: policy.id,
3433                        count: budget,
3434                    });
3435                    // The lock is held briefly now, so this is not a turn: a
3436                    // Windows drain, the fence, or another process.
3437                    self.director.start_failed(policy, None);
3438                    break;
3439                }
3440            };
3441            let lock_wait = waiting_since.elapsed();
3442
3443            // The read and the decision are both inside the hold, and so is the
3444            // journalled reservation below. Two concurrent passes therefore serialise on the
3445            // whole sequence rather than on the decision alone -- reading the
3446            // headroom outside the lock is the shape in which two policies both
3447            // find room for the last slot.
3448            let (allocation, attempts) = match self.allocate_over(policy, demand, reserved).await {
3449                Ok(decided) => decided,
3450                Err(failure) => {
3451                    drop(guard);
3452                    self.report_allocation_failure(report, &failure);
3453                    self.director.start_failed(policy, None);
3454                    break;
3455                }
3456            };
3457
3458            if decided.is_none() {
3459                // The under-lock decision may be smaller than the pre-check, and
3460                // never larger: `min` rather than assignment, so a later hold
3461                // cannot raise the bound either.
3462                budget = budget.min(allocation.to_start);
3463                decided = Some(allocation.clone());
3464            }
3465
3466            // Either stop is sufficient on its own in the well-behaved case;
3467            // neither is sufficient when the launcher lags. See the doc comment.
3468            if allocation.starts_nothing() || budget == 0 {
3469                drop(guard);
3470                break;
3471            }
3472
3473            // Memory, last: every ceiling granted this runner. Read under the
3474            // hold with the set the grant was decided over, so a runner this
3475            // pass reserved a moment ago holds its estimate here already.
3476            match self.memory.admit(policy, &attempts) {
3477                MemoryVerdict::Defer(figures) => {
3478                    drop(guard);
3479                    report.memory_deferred = report.memory_deferred.saturating_add(budget);
3480                    self.events.emit(LifecycleEvent::MemoryShort {
3481                        policy: policy.id,
3482                        figures,
3483                        started: false,
3484                    });
3485                    // A directed launch waits like one that could not start:
3486                    // past `START_LIMIT` it is dropped, and the coordinator
3487                    // places its job on another host.
3488                    self.director.start_failed(policy, None);
3489                    break;
3490                }
3491                MemoryVerdict::AdmitAlone(figures) => {
3492                    self.events.emit(LifecycleEvent::MemoryShort {
3493                        policy: policy.id,
3494                        figures,
3495                        started: true,
3496                    });
3497                    self.memory.admitted(policy);
3498                }
3499                MemoryVerdict::Admit(_) | MemoryVerdict::Unchecked => self.memory.admitted(policy),
3500            }
3501
3502            let created = self
3503                .launcher
3504                .launch(LaunchRequest {
3505                    host: &self.host,
3506                    policy,
3507                    allocation_guard: &guard,
3508                })
3509                .await;
3510            let hold = guard.launch_hold();
3511            drop(guard);
3512
3513            match created {
3514                Ok(attempt) => {
3515                    let id = attempt.id;
3516                    // At once, not once it is up: the launch has its runner, and
3517                    // the runner's report names the launch it was started for.
3518                    self.director.launched(policy, id);
3519                    budget -= 1;
3520                    // Carried across policies for the rest of this pass, so the
3521                    // host-wide total cannot be computed from a set that is
3522                    // missing it. See `RunnerLauncher::launch`.
3523                    reserved.push(Reserved {
3524                        policy: policy.clone(),
3525                        attempt,
3526                        waits: ReservationWaits {
3527                            order: order_wait,
3528                            lock: lock_wait,
3529                        },
3530                        reserved_at: std::time::Instant::now(),
3531                        _hold: hold,
3532                    });
3533                }
3534                Err(failure) => {
3535                    self.note_start_failed(policy, None, &failure);
3536                    break;
3537                }
3538            }
3539        }
3540
3541        let allocation = decided.unwrap_or(intent);
3542        self.emit_allocation(&allocation);
3543        report.allocations.push(allocation);
3544    }
3545
3546    /// Oldest first across targets: `policy`'s launch reserves after the older
3547    /// launches of this host's other targets, whose loops are told of them as
3548    /// soon as they are accepted. A reservation is a lock hold and a journal
3549    /// write, so this is short, and only a loop that cannot reserve at all makes
3550    /// it long: see [`OLDER_LAUNCH_TURN`]. Answers how long it waited.
3551    async fn let_older_launches_reserve(&self, policy: &ScalePolicy) -> Duration {
3552        let began = tokio::time::Instant::now();
3553        let mut older = self.director.older_waiting(policy);
3554        let mut limit = began + OLDER_LAUNCH_TURN;
3555        while older > 0 {
3556            if tokio::time::Instant::now() >= limit {
3557                tracing::warn!(
3558                    event = "older_launch_passed_over",
3559                    policy_id = %policy.id,
3560                    count = older,
3561                );
3562                self.director.pass_over_older(policy);
3563                break;
3564            }
3565            tokio::time::sleep(OLDER_LAUNCH_POLL).await;
3566            let still = self.director.older_waiting(policy);
3567            if still < older {
3568                limit = tokio::time::Instant::now() + OLDER_LAUNCH_TURN;
3569            }
3570            older = still;
3571        }
3572        began.elapsed()
3573    }
3574
3575    /// Bring up what this pass reserved, together and outside the allocation
3576    /// lock, so one slow package holds up neither another runner nor another
3577    /// target. How many prepare at once is the launcher's to bound.
3578    ///
3579    /// Awaited within the pass, because supervision is what would otherwise see
3580    /// these attempts half made: one target's attempts are only ever supervised
3581    /// by its own loop, which is here.
3582    async fn finish_reserved(&self, reserved: Vec<Reserved>, report: &mut ReconcileReport) {
3583        let finished =
3584            futures::future::join_all(reserved.into_iter().map(|reserved| self.bring_up(reserved)))
3585                .await;
3586        let started = finished.into_iter().filter(|started| *started).count();
3587        report.started = report
3588            .started
3589            .saturating_add(u16::try_from(started).unwrap_or(u16::MAX));
3590    }
3591
3592    /// One reserved runner, up or failed: whether it is up.
3593    async fn bring_up(&self, reserved: Reserved) -> bool {
3594        let id = reserved.attempt.id;
3595        let mut waits = reserved.waits;
3596        waits.lock += reserved.reserved_at.elapsed();
3597        let result = self
3598            .launcher
3599            .finish_launch(&reserved.policy, reserved.attempt, waits)
3600            .await;
3601        // Told here and not after the others are up: the journal already
3602        // says this one failed, and the attempt reports read it.
3603        match &result {
3604            Ok(_) => self.events.emit(LifecycleEvent::RunnerStarted {
3605                policy: reserved.policy.id,
3606                attempt: id,
3607            }),
3608            Err(failure) => self.note_start_failed(&reserved.policy, Some(id), failure),
3609        }
3610        result.is_ok()
3611    }
3612
3613    fn note_start_failed(
3614        &self,
3615        policy: &ScalePolicy,
3616        attempt: Option<AttemptId>,
3617        failure: &LaunchFailure,
3618    ) {
3619        self.events.emit(LifecycleEvent::RunnerStartFailed {
3620            policy: policy.id,
3621            reason: failure_reason_kind(&failure.reason),
3622        });
3623        self.director.start_failed(policy, attempt);
3624    }
3625
3626    /// The attempt set the host holds, plus everything this pass has already
3627    /// created.
3628    ///
3629    /// The merge is by [`RunnerAttempt::id`], so a launcher that makes its
3630    /// launches visible before returning -- which
3631    /// [`RunnerLauncher::launch`] asks for -- contributes each attempt once, and
3632    /// one that lags still cannot hide a runner from the host-wide total. The
3633    /// ceiling therefore holds on the strength of this function rather than on
3634    /// the strength of an implementer honouring a comment.
3635    ///
3636    /// # Errors
3637    /// Whatever [`RunnerLauncher::attempts`] reported.
3638    async fn host_attempts(
3639        &self,
3640        reserved: &[Reserved],
3641    ) -> Result<Vec<RunnerAttempt>, LaunchFailure> {
3642        let mut attempts = self.launcher.attempts().await?;
3643
3644        // Each `launch` creates one runtime, so each must answer with an
3645        // identifier no other attempt has. Two entries sharing one here are two
3646        // runtimes the host-wide total below counts once, which is the ceiling
3647        // failing silently -- so a development build stops at the first
3648        // duplicate instead. `RunnerLauncher::launch` states the requirement;
3649        // this is what makes it findable.
3650        debug_assert!(
3651            reserved
3652                .iter()
3653                .map(|reserved| reserved.attempt.id)
3654                .collect::<BTreeSet<AttemptId>>()
3655                .len()
3656                == reserved.len(),
3657            "`RunnerLauncher::launch` returned an AttemptId this pass had already seen; \
3658             the host ceiling is enforced against a set keyed on that identifier, so a \
3659             duplicate is two runtimes counted as one"
3660        );
3661
3662        let known: BTreeSet<AttemptId> = attempts.iter().map(|attempt| attempt.id).collect();
3663        attempts.extend(
3664            reserved
3665                .iter()
3666                .filter(|reserved| !known.contains(&reserved.attempt.id))
3667                .map(|reserved| reserved.attempt.clone()),
3668        );
3669        Ok(attempts)
3670    }
3671
3672    fn report_allocation_failure(&self, report: &mut ReconcileReport, failure: &AllocationFailure) {
3673        match failure {
3674            AllocationFailure::HostCapacity(reason) => {
3675                report.host_capacity_unreadable = report.host_capacity_unreadable.saturating_add(1);
3676                self.events
3677                    .emit(LifecycleEvent::HostCapacityUnreadable { reason: *reason });
3678            }
3679            AllocationFailure::Attempts(failure) => {
3680                self.report_unreadable_attempts(report, failure)
3681            }
3682        }
3683    }
3684
3685    /// The attempt set could not be read, so nothing may be decided from it.
3686    ///
3687    /// Counted rather than swallowed for the reason the module documentation
3688    /// gives: an unreadable set and an idle host produce the same *number* and
3689    /// demand opposite actions, so the difference has to survive into the
3690    /// report.
3691    fn report_unreadable_attempts(&self, report: &mut ReconcileReport, failure: &LaunchFailure) {
3692        report.attempts_unreadable = report.attempts_unreadable.saturating_add(1);
3693        // The variant, never a literal and never the detail. A hand-written
3694        // `"attempts_unreadable"` said only what the event's own name already
3695        // said, and threw away the one thing the field is for -- *which* failure
3696        // it was. `FailureReason::Other` carries free text that must not reach
3697        // an event, which is what `failure_reason_kind` is for and what
3698        // `a_cleanup_that_cannot_succeed_...` pins for the sibling path.
3699        self.events.emit(LifecycleEvent::AttemptsUnreadable {
3700            reason: failure_reason_kind(&failure.reason),
3701        });
3702    }
3703
3704    /// Remove the runtimes of attempts that have already concluded.
3705    ///
3706    /// `is_concluded` and not `is_terminal`: `cleaned` is terminal and already
3707    /// done, and `busy` is not terminal at all. That is what makes it impossible
3708    /// for this path to reach a runner executing a job.
3709    async fn clean_terminal_attempts(&mut self, report: &mut ReconcileReport) {
3710        let attempts = match self.launcher.attempts().await {
3711            Ok(attempts) => attempts,
3712            Err(failure) => {
3713                self.report_unreadable_attempts(report, &failure);
3714                return;
3715            }
3716        };
3717        self.clean_backoff
3718            .retain(&attempts.iter().map(|attempt| attempt.id).collect());
3719        let now = self.clock.now();
3720        for attempt in attempts {
3721            if !attempt.state().is_concluded() {
3722                continue;
3723            }
3724            let Some(outcome) = attempt.outcome() else {
3725                continue;
3726            };
3727            // Backed off after failing: not tried, so neither cleaned nor a
3728            // fresh failure to report.
3729            if !self.clean_backoff.is_due(attempt.id, now) {
3730                continue;
3731            }
3732            let kind = OutcomeKind::of(outcome);
3733            match self.launcher.clean(attempt.id).await {
3734                Ok(()) => {
3735                    self.clean_backoff.succeeded(attempt.id);
3736                    report.cleaned = report.cleaned.saturating_add(1);
3737                    if kind.is_failure() {
3738                        report.failures = report.failures.saturating_add(1);
3739                    } else if kind == OutcomeKind::IdleExit {
3740                        // The surplus case. Counted apart from a failure because
3741                        // `g2` renders it apart, and because an operator told
3742                        // that a normal surplus exit is an error goes hunting a
3743                        // fault that does not exist.
3744                        report.idle_exits = report.idle_exits.saturating_add(1);
3745                    }
3746                    self.events.emit(LifecycleEvent::AttemptCleaned {
3747                        policy: attempt.policy_id,
3748                        attempt: attempt.id,
3749                        outcome: kind,
3750                    });
3751                }
3752                // A runtime directory that cannot be removed is retried on every
3753                // poll. Silently, before this arm existed: no event, no counter,
3754                // no report field, so a cleanup that can never succeed was an
3755                // invisible permanent loop. It wedges no capacity -- a terminal
3756                // attempt already stopped counting -- but this module's
3757                // organising principle is the things that go wrong silently, and
3758                // `clean` returns a `Result` precisely so the caller can say
3759                // something. It is retried with per-attempt backoff
3760                // ([`CLEAN_RETRY_INITIAL`]) so the saying is not a flood.
3761                Err(failure) => {
3762                    self.clean_backoff.failed(attempt.id, now);
3763                    report.clean_failures = report.clean_failures.saturating_add(1);
3764                    self.events.emit(LifecycleEvent::AttemptCleanFailed {
3765                        policy: attempt.policy_id,
3766                        attempt: attempt.id,
3767                        reason: failure_reason_kind(&failure.reason),
3768                    });
3769                }
3770            }
3771        }
3772    }
3773
3774    /// Reclaim what can be reclaimed for one policy, and nothing else.
3775    ///
3776    /// **A busy attempt is never removed.** `04-subsystem-contracts.md`:
3777    /// *"`busy` cannot transition to cleanup due to a scale-down request"*.
3778    /// Capacity comes back when an attempt reaches a terminal state and at no
3779    /// other time, so a scale-down against a host full of busy runners removes
3780    /// nothing, changes nothing, and says so.
3781    pub async fn scale_down(&self, policy: &ScalePolicy) -> ScaleDownReport {
3782        let mut report = ScaleDownReport::default();
3783        let attempts = match self.launcher.attempts().await {
3784            Ok(attempts) => attempts,
3785            Err(failure) => {
3786                // The same rule as everywhere else, and this was the one place
3787                // it was still broken: an unreadable set is not an empty one,
3788                // and a bare `default()` here reported all zeros -- byte for
3789                // byte an idle host with nothing to reclaim.
3790                self.events.emit(LifecycleEvent::AttemptsUnreadable {
3791                    reason: failure_reason_kind(&failure.reason),
3792                });
3793                report.attempts_unreadable = true;
3794                return report;
3795            }
3796        };
3797        for attempt in attempts {
3798            if attempt.policy_id != policy.id {
3799                continue;
3800            }
3801            match attempt.state() {
3802                AttemptState::Busy => {
3803                    report.refused_busy = report.refused_busy.saturating_add(1);
3804                    self.events.emit(LifecycleEvent::ScaleDownRefused {
3805                        policy: policy.id,
3806                        attempt: attempt.id,
3807                    });
3808                }
3809                state if state.is_concluded() => {
3810                    let kind = attempt
3811                        .outcome()
3812                        .map_or(OutcomeKind::Failed, OutcomeKind::of);
3813                    match self.launcher.clean(attempt.id).await {
3814                        Ok(()) => {
3815                            report.removed = report.removed.saturating_add(1);
3816                            self.events.emit(LifecycleEvent::AttemptCleaned {
3817                                policy: policy.id,
3818                                attempt: attempt.id,
3819                                outcome: kind,
3820                            });
3821                        }
3822                        Err(failure) => {
3823                            report.clean_failures = report.clean_failures.saturating_add(1);
3824                            self.events.emit(LifecycleEvent::AttemptCleanFailed {
3825                                policy: policy.id,
3826                                attempt: attempt.id,
3827                                reason: failure_reason_kind(&failure.reason),
3828                            });
3829                        }
3830                    }
3831                }
3832                AttemptState::Cleaned => {}
3833                // `allocated`, `jit_received`, `starting`, `idle`: live, holding
3834                // a slot, and not this function's to end.
3835                _ => report.retained = report.retained.saturating_add(1),
3836            }
3837        }
3838        report
3839    }
3840
3841    fn emit_allocation(&self, allocation: &Allocation) {
3842        self.events.emit(LifecycleEvent::Allocated {
3843            policy: allocation.policy_id,
3844            demand: allocation.demand,
3845            desired: allocation.desired,
3846            active_owned: allocation.active_owned,
3847            headroom: allocation.headroom_before,
3848            to_start: allocation.to_start,
3849            limiting: allocation.limiting_factor,
3850        });
3851    }
3852}
3853
3854/// One policy's demand, from the reading its target answered with.
3855///
3856/// A repository target tallies its own repository's queued jobs; an organization
3857/// target tallies every repository its scope covered, because one policy watching
3858/// an organization serves any repository in it.
3859///
3860/// # Why this takes a whole policy rather than a target and a label set
3861///
3862/// Because both halves have to come from the same policy, and a signature that
3863/// took them separately made it possible for them not to. The predecessor took a
3864/// `&ScaleTarget` alone and could not filter at all; the obvious repair was to
3865/// add a `&RoutingLabels` beside it, and at three call sites — two of them in
3866/// tests — nothing would have caught passing one policy's target with another
3867/// policy's labels. It compiles, it runs, and it silently serves the wrong
3868/// repository's queue.
3869///
3870/// # A monitor-only policy has no labels, and cannot reach here
3871///
3872/// [`Reconciler::reconcile`] filters on [`ScalePolicy::owns_runners`] before any
3873/// demand request is issued (D19), so the `None` arm is unreachable rather than
3874/// merely unlikely. It returns an empty tally instead of unwrapping, because a
3875/// panic in the reconciliation loop would take the daemon down over a policy
3876/// that was only ever going to start nothing.
3877fn demand_for(policy: &ScalePolicy, reading: &QueuedDemand) -> DemandTally {
3878    let Some(labels) = policy.routing_labels() else {
3879        debug_assert!(
3880            false,
3881            "a monitor-only policy is skipped before the demand poll (D19)"
3882        );
3883        return DemandTally::default();
3884    };
3885
3886    match &policy.target {
3887        ScaleTarget::Repository(repository) => labels.tally(reading.jobs_for(repository)),
3888        ScaleTarget::Organization(_) => labels.tally(reading.jobs()),
3889    }
3890}
3891
3892/// Tally one shared target reading using GitHub's required-label subset rule.
3893/// Refuse ambiguous ownership, including inactive local profiles: disabling an
3894/// isolated profile must not redirect its broad-label demand to a native sibling.
3895fn route_demand(
3896    all_policies: &[ScalePolicy],
3897    profiles: &[&ScalePolicy],
3898    reading: &QueuedDemand,
3899) -> (
3900    BTreeMap<PolicyId, DemandTally>,
3901    BTreeMap<RoutingRefusal, u32>,
3902) {
3903    let mut tallies: BTreeMap<PolicyId, DemandTally> = profiles
3904        .iter()
3905        .map(|policy| (policy.id, demand_for(policy, reading)))
3906        .collect();
3907    let mut refusals: BTreeMap<RoutingRefusal, u32> = BTreeMap::new();
3908    let Some(first) = profiles.first() else {
3909        return (tallies, refusals);
3910    };
3911    let owners: Vec<_> = all_policies
3912        .iter()
3913        .filter(|policy| policy.target == first.target && policy.host_id == first.host_id)
3914        .filter(|policy| policy.owns_runners())
3915        .collect();
3916    let jobs: Vec<&runner_manager_domain::policy::RunsOn> = match &first.target {
3917        ScaleTarget::Repository(repository) => reading.jobs_for(repository).iter().collect(),
3918        ScaleTarget::Organization(_) => reading.jobs().collect(),
3919    };
3920    for job in jobs {
3921        let Ok(_) = job.required_labels() else {
3922            // `demand_for` already preserves this as unresolvable for each profile.
3923            continue;
3924        };
3925        let matched: Vec<PolicyId> = owners
3926            .iter()
3927            .filter(|policy| {
3928                policy
3929                    .routing_labels()
3930                    .is_some_and(|labels| labels.matches(job).is_match())
3931            })
3932            .map(|policy| policy.id)
3933            .collect();
3934        if matched.len() > 1 {
3935            *refusals
3936                .entry(RoutingRefusal::OverlappingProfiles)
3937                .or_default() += 1;
3938            for id in matched {
3939                if let Some(tally) = tallies.get_mut(&id) {
3940                    tally.matched -= 1;
3941                    tally.not_matched += 1;
3942                }
3943            }
3944        }
3945    }
3946    (tallies, refusals)
3947}
3948
3949/// How urgently one failure should slow the loop down.
3950///
3951/// Ordering matters only for picking the worst of several targets: an outage
3952/// outranks a rate limit because backing off a socket that is not answering is
3953/// the safer error, and both outrank a per-target rejection that says nothing
3954/// about the credential as a whole.
3955const fn severity(state: &RefreshState) -> u8 {
3956    match state {
3957        RefreshState::Offline => 5,
3958        RefreshState::RateLimited(_) => 4,
3959        RefreshState::LockedOut { .. } => 3,
3960        RefreshState::Unauthorized => 2,
3961        RefreshState::Forbidden { .. } | RefreshState::Failed { .. } => 1,
3962        RefreshState::Cancelled | RefreshState::Ready(_) => 0,
3963    }
3964}
3965
3966/// Why one target could not be read, as a fixed, credential-free name.
3967///
3968/// Deliberately not a [`PollPace`]: a pace describes the *schedule*, which is a
3969/// property of the whole pass, and stamping one onto a single target would have
3970/// meant inventing a `consecutive` count for a target that has none. What an
3971/// event needs here is the reason, and `c3`'s [`RefreshState`] already names it.
3972///
3973/// `RefreshState::Failed` carries GitHub's own message and
3974/// `RefreshState::Forbidden` may carry one too. Neither reaches the event: this
3975/// returns the variant, for the reason [`failure_reason_kind`] states.
3976const fn unreadable_reason(state: &RefreshState) -> &'static str {
3977    match state {
3978        RefreshState::Ready(_) => "ready",
3979        RefreshState::Offline => "offline",
3980        RefreshState::RateLimited(_) => "rate_limited",
3981        RefreshState::LockedOut { .. } => "locked_out",
3982        RefreshState::Unauthorized => "unauthorized",
3983        RefreshState::Forbidden { .. } => "forbidden",
3984        RefreshState::Failed { .. } => "failed",
3985        RefreshState::Cancelled => "cancelled",
3986    }
3987}
3988
3989#[cfg(test)]
3990mod tests {
3991    use super::*;
3992
3993    /// The reading that said `healthy` for 28 hours while nothing worked.
3994    ///
3995    /// A daemon every one of whose targets answered `401` kept writing a fresh
3996    /// `last GitHub contact`, because an unauthorized target is `unreadable`
3997    /// rather than a `failure`. Both that record and the `service status` built
3998    /// on it were used as evidence during the investigation, and both were
3999    /// wrong; see `docs/spikes/token-expiry-and-renewal.md`.
4000    #[test]
4001    fn a_pass_that_reached_no_target_does_not_claim_it_reached_github() {
4002        let mut report = ReconcileReport::default();
4003        assert!(
4004            !report.reached_github(),
4005            "a pass that polled nothing -- every policy draining, owned elsewhere, or \
4006             monitor-only -- reached nobody. This is the case the old guard let through, and \
4007             the only one it ever let through."
4008        );
4009
4010        report.unreadable.push(PolicyId::from_u128(1));
4011        assert!(
4012            !report.reached_github(),
4013            "every target this pass tried was unreadable, so there is no contact to record"
4014        );
4015
4016        report.targets_read = 1;
4017        assert!(
4018            report.reached_github(),
4019            "one target answering is contact, whatever else failed alongside it"
4020        );
4021
4022        // `allocations` deliberately does not count: a policy this host does
4023        // not own is allocated for with no demand and without polling anything,
4024        // so a pass where every poll failed can still carry allocations.
4025        let mut unowned = ReconcileReport::default();
4026        unowned.unreadable.push(PolicyId::from_u128(2));
4027        unowned.allocations.push(Allocation {
4028            policy_id: PolicyId::from_u128(2),
4029            demand: 0,
4030            desired: 0,
4031            active_owned: 0,
4032            headroom_before: 0,
4033            to_start: 0,
4034            limiting_factor: LimitingFactor::Demand,
4035        });
4036        assert!(
4037            !unowned.reached_github(),
4038            "an allocation is not evidence that GitHub answered"
4039        );
4040    }
4041
4042    /// The claim the old guard rested on, checked rather than assumed.
4043    ///
4044    /// `report.failure.is_none()` was believed to be compatible with an
4045    /// all-unauthorized pass. It is not: `unreadable` is pushed only from the
4046    /// `Failed` arm and `failure` is the maximum over every `Failed` reading,
4047    /// so guarding on `failure.is_none() && reached_github()` would have been
4048    /// `failure.is_none()` with extra words. This pins the severity that makes
4049    /// it so, because a future `severity(Unauthorized) == 0` would quietly
4050    /// restore the belief.
4051    #[test]
4052    fn an_unauthorized_target_is_a_failure_and_not_merely_unreadable() {
4053        assert!(
4054            severity(&RefreshState::Unauthorized) > 0,
4055            "an unauthorized reading must survive `max_by_key(severity)` into `report.failure`, \
4056             or a pass where every target was refused would report no failure at all"
4057        );
4058    }
4059
4060    use std::sync::atomic::AtomicUsize;
4061
4062    use std::num::NonZeroU16;
4063
4064    use runner_manager_domain::attempt::PersistedAttempt;
4065    use runner_manager_domain::model::{CachePolicy, HostId};
4066    use runner_manager_domain::policy::PolicyMode;
4067    use runner_manager_domain::workspace::WorkspaceKind;
4068    use runner_manager_github::rest::RateLimited;
4069    use runner_manager_testkit::clock::FakeClock;
4070    use runner_manager_testkit::fixtures;
4071    use runner_manager_testkit::github::FakeGithub;
4072
4073    // =======================================================================
4074    // Fakes
4075    // =======================================================================
4076
4077    fn host_with(capacity: u16) -> Host {
4078        fixtures::host().capacity(capacity).build()
4079    }
4080
4081    fn repo(raw: &str) -> OwnerRepo {
4082        OwnerRepo::parse(raw).expect("a valid OWNER/REPO")
4083    }
4084
4085    /// An `active`, enabled autoscale policy on the fixture host.
4086    use runner_manager_domain::policy::RunsOn;
4087
4088    fn policy(id: u128, target: &str, max: u16) -> ScalePolicy {
4089        fixtures::policy()
4090            .id(PolicyId::from_u128(id))
4091            .repository(target)
4092            .autoscale("home", max)
4093            .active()
4094            .build()
4095    }
4096
4097    /// The host label every policy in these tests carries.
4098    ///
4099    /// `policy` above builds through `fixtures::policy().autoscale("home", …)`,
4100    /// which derives `rm-home-win-x64`. A job fixture that did not carry it
4101    /// would be filtered out as another host's work, so the two are tied
4102    /// together here rather than repeated as a literal at each call site.
4103    const HOST_LABEL: &str = "rm-home-win-x64";
4104
4105    /// `n` queued jobs this host's policies match.
4106    ///
4107    /// The ordinary demand fixture. Since the reversal of the run-counting
4108    /// decision the unit `e1` clamps is a job, so a test wanting demand `n` asks
4109    /// for `n` jobs rather than for `n` runs.
4110    fn jobs(n: usize) -> Vec<RunsOn> {
4111        fixtures::queued_jobs(&[HOST_LABEL], n)
4112    }
4113
4114    /// `e3`, faked: an attempt table and a launch counter, no process anywhere.
4115    #[derive(Debug, Default)]
4116    struct FakeLauncher {
4117        capacity: Mutex<Option<NonZeroU16>>,
4118        capacity_unreadable: AtomicBool,
4119        capacity_after_launch: Mutex<Option<NonZeroU16>>,
4120        attempts: Mutex<Vec<RunnerAttempt>>,
4121        next_id: AtomicU64,
4122        launches: AtomicUsize,
4123        cleaned: Mutex<Vec<AttemptId>>,
4124        /// Yields this many times between reading the attempt set and recording
4125        /// a new one, so an unserialised allocator has a window to be wrong in.
4126        yields_before_recording: usize,
4127        /// Reports success without the attempt ever becoming visible, which is
4128        /// the shape a slow journal write has. Every grant then looks like the
4129        /// first.
4130        forgetful: bool,
4131        fail_next: Mutex<Option<FailureReason>>,
4132        /// Reports that the attempt set cannot be read at all, which is the one
4133        /// answer a caller must never confuse with an idle host.
4134        attempts_fail: Mutex<bool>,
4135        /// Refuses every cleanup, so the silent-retry path has something to be
4136        /// loud about.
4137        clean_fails: bool,
4138        replacements: Mutex<Vec<ReplacementIntent>>,
4139    }
4140
4141    impl FakeLauncher {
4142        fn new() -> Self {
4143            Self::default()
4144        }
4145
4146        fn with_yields(mut self, yields: usize) -> Self {
4147            self.yields_before_recording = yields;
4148            self
4149        }
4150
4151        fn forgetful() -> Self {
4152            Self {
4153                forgetful: true,
4154                ..Self::default()
4155            }
4156        }
4157
4158        fn seeded(self, attempts: Vec<RunnerAttempt>) -> Self {
4159            *self.attempts.lock().unwrap() = attempts;
4160            self
4161        }
4162
4163        fn launches(&self) -> usize {
4164            self.launches.load(Ordering::SeqCst)
4165        }
4166
4167        fn snapshot(&self) -> Vec<RunnerAttempt> {
4168            self.attempts.lock().unwrap().clone()
4169        }
4170
4171        fn live_count(&self) -> usize {
4172            self.snapshot()
4173                .iter()
4174                .filter(|a| a.counts_against_capacity())
4175                .count()
4176        }
4177
4178        fn fail_next(&self, reason: FailureReason) {
4179            *self.fail_next.lock().unwrap() = Some(reason);
4180        }
4181
4182        fn fail_attempts(&self, failing: bool) {
4183            *self.attempts_fail.lock().unwrap() = failing;
4184        }
4185
4186        fn refusing_cleanup(attempts: Vec<RunnerAttempt>) -> Self {
4187            Self {
4188                clean_fails: true,
4189                ..Self::default()
4190            }
4191            .seeded(attempts)
4192        }
4193
4194        fn cleaned(&self) -> Vec<AttemptId> {
4195            self.cleaned.lock().unwrap().clone()
4196        }
4197
4198        fn replacing(self, intent: ReplacementIntent) -> Self {
4199            self.replacements.lock().unwrap().push(intent);
4200            self
4201        }
4202    }
4203
4204    #[async_trait::async_trait]
4205    impl RunnerLauncher for FakeLauncher {
4206        async fn host_capacity(&self, host: &Host) -> Result<NonZeroU16, HostCapacityError> {
4207            if self.capacity_unreadable.load(Ordering::SeqCst) {
4208                return Err(HostCapacityError::JournalUnreadable);
4209            }
4210            Ok(self.capacity.lock().unwrap().unwrap_or(host.host_capacity))
4211        }
4212
4213        async fn supervise(
4214            &self,
4215            policy: &ScalePolicy,
4216        ) -> Result<Vec<ReplacementIntent>, LaunchFailure> {
4217            let mut replacements = self.replacements.lock().unwrap();
4218            let selected: Vec<_> = replacements
4219                .extract_if(.., |intent| intent.policy == policy.id)
4220                .collect();
4221            if !selected.is_empty() {
4222                let retired: BTreeSet<_> = selected
4223                    .iter()
4224                    .map(|intent| intent.previous_attempt)
4225                    .collect();
4226                self.attempts
4227                    .lock()
4228                    .unwrap()
4229                    .retain(|attempt| !retired.contains(&attempt.id));
4230            }
4231            Ok(selected)
4232        }
4233
4234        async fn attempts(&self) -> Result<Vec<RunnerAttempt>, LaunchFailure> {
4235            if *self.attempts_fail.lock().unwrap() {
4236                return Err(LaunchFailure::new(FailureReason::Other(
4237                    "the journal could not be read".into(),
4238                )));
4239            }
4240            Ok(self.snapshot())
4241        }
4242
4243        async fn launch(&self, request: LaunchRequest<'_>) -> Result<RunnerAttempt, LaunchFailure> {
4244            if let Some(reason) = self.fail_next.lock().unwrap().take() {
4245                return Err(LaunchFailure::new(reason));
4246            }
4247            // The window an unserialised caller would lose the race in.
4248            for _ in 0..self.yields_before_recording {
4249                tokio::task::yield_now().await;
4250            }
4251            let id =
4252                AttemptId::from_u128(u128::from(self.next_id.fetch_add(1, Ordering::SeqCst) + 1));
4253            let created = RunnerAttempt::allocate(
4254                id,
4255                request.policy.id,
4256                "runtime/p/a",
4257                request.host.created_at,
4258            );
4259            self.launches.fetch_add(1, Ordering::SeqCst);
4260            if let Some(capacity) = self.capacity_after_launch.lock().unwrap().take() {
4261                *self.capacity.lock().unwrap() = Some(capacity);
4262            }
4263            if !self.forgetful {
4264                self.attempts.lock().unwrap().push(created.clone());
4265            }
4266            Ok(created)
4267        }
4268
4269        async fn clean(&self, attempt: AttemptId) -> Result<(), LaunchFailure> {
4270            if self.clean_fails {
4271                return Err(LaunchFailure::new(FailureReason::Other(
4272                    "the runtime directory is locked".into(),
4273                )));
4274            }
4275            self.cleaned.lock().unwrap().push(attempt);
4276            let mut attempts = self.attempts.lock().unwrap();
4277            attempts.retain(|a| a.id != attempt);
4278            Ok(())
4279        }
4280    }
4281
4282    /// A demand source a test programs directly, with no gateway underneath.
4283    #[derive(Debug, Default)]
4284    struct FakeDemand {
4285        outcome: Mutex<Option<PollOutcome>>,
4286        /// Answers programmed for one target, which beat the blanket one.
4287        per_target: Mutex<BTreeMap<ScaleTarget, PollOutcome>>,
4288        scopes: Mutex<Vec<ActivityScope>>,
4289    }
4290
4291    impl FakeDemand {
4292        fn ready(count: u32, repository: &OwnerRepo) -> Self {
4293            let fake = Self::default();
4294            fake.set(PollOutcome::Ready(QueuedDemand::of(
4295                repository.clone(),
4296                jobs(count as usize),
4297            )));
4298            fake
4299        }
4300
4301        fn failing(state: RefreshState) -> Self {
4302            let fake = Self::default();
4303            fake.set(PollOutcome::Failed(state));
4304            fake
4305        }
4306
4307        fn set(&self, outcome: PollOutcome) {
4308            *self.outcome.lock().unwrap() = Some(outcome);
4309        }
4310
4311        /// Program one target's answer, overriding the blanket one.
4312        fn set_for(&self, target: &ScaleTarget, outcome: PollOutcome) {
4313            self.per_target
4314                .lock()
4315                .unwrap()
4316                .insert(target.clone(), outcome);
4317        }
4318
4319        fn polls(&self) -> Vec<ActivityScope> {
4320            self.scopes.lock().unwrap().clone()
4321        }
4322    }
4323
4324    #[async_trait::async_trait]
4325    impl DemandSource for FakeDemand {
4326        async fn poll(&self, scope: &ActivityScope) -> PollOutcome {
4327            self.scopes.lock().unwrap().push(scope.clone());
4328            if let Some(outcome) = self.per_target.lock().unwrap().get(scope.target()) {
4329                return outcome.clone();
4330            }
4331            self.outcome
4332                .lock()
4333                .unwrap()
4334                .clone()
4335                .unwrap_or(PollOutcome::Ready(QueuedDemand::default()))
4336        }
4337    }
4338
4339    #[derive(Debug, Default)]
4340    struct FakeDirectory {
4341        repositories: Vec<OwnerRepo>,
4342        calls: AtomicUsize,
4343    }
4344
4345    impl FakeDirectory {
4346        fn of(repositories: Vec<OwnerRepo>) -> Self {
4347            Self {
4348                repositories,
4349                calls: AtomicUsize::new(0),
4350            }
4351        }
4352
4353        fn calls(&self) -> usize {
4354            self.calls.load(Ordering::SeqCst)
4355        }
4356    }
4357
4358    #[async_trait::async_trait]
4359    impl RepositoryDirectory for FakeDirectory {
4360        async fn repositories(&self, _org: &Org) -> Result<Vec<OwnerRepo>, InventoryError> {
4361            self.calls.fetch_add(1, Ordering::SeqCst);
4362            Ok(self.repositories.clone())
4363        }
4364    }
4365
4366    /// A lock that grants everything and counts how many holders it had at once.
4367    ///
4368    /// The counter is the assertion: "under simulated lock contention" is only
4369    /// meaningful if something measures that the contention was actually
4370    /// serialised.
4371    #[derive(Debug)]
4372    struct CountingLock {
4373        inner: InProcessAllocationLock,
4374        concurrent: Arc<AtomicUsize>,
4375        peak: Arc<AtomicUsize>,
4376        acquisitions: Arc<AtomicUsize>,
4377    }
4378
4379    impl CountingLock {
4380        fn new() -> Self {
4381            Self {
4382                inner: InProcessAllocationLock::new(),
4383                concurrent: Arc::new(AtomicUsize::new(0)),
4384                peak: Arc::new(AtomicUsize::new(0)),
4385                acquisitions: Arc::new(AtomicUsize::new(0)),
4386            }
4387        }
4388
4389        fn peak(&self) -> usize {
4390            self.peak.load(Ordering::SeqCst)
4391        }
4392
4393        fn acquisitions(&self) -> usize {
4394            self.acquisitions.load(Ordering::SeqCst)
4395        }
4396    }
4397
4398    #[derive(Debug)]
4399    struct CountingGuard {
4400        _inner: AllocationGuard,
4401        concurrent: Arc<AtomicUsize>,
4402    }
4403
4404    impl Drop for CountingGuard {
4405        fn drop(&mut self) {
4406            self.concurrent.fetch_sub(1, Ordering::SeqCst);
4407        }
4408    }
4409
4410    #[async_trait::async_trait]
4411    impl AllocationLock for CountingLock {
4412        async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
4413            let inner = self.inner.acquire().await?;
4414            self.acquisitions.fetch_add(1, Ordering::SeqCst);
4415            let now = self.concurrent.fetch_add(1, Ordering::SeqCst) + 1;
4416            self.peak.fetch_max(now, Ordering::SeqCst);
4417            Ok(AllocationGuard::new(CountingGuard {
4418                _inner: inner,
4419                concurrent: Arc::clone(&self.concurrent),
4420            }))
4421        }
4422    }
4423
4424    /// The lock that is not one: what the host looks like with the serialisation
4425    /// removed. Used only by the control half of the contention test.
4426    #[derive(Debug, Default)]
4427    struct NoLock;
4428
4429    #[async_trait::async_trait]
4430    impl AllocationLock for NoLock {
4431        async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
4432            Ok(AllocationGuard::new(()))
4433        }
4434    }
4435
4436    #[tokio::test]
4437    async fn a_wsl_drain_request_fences_the_exact_pre_launch_boundary() {
4438        use runner_manager_platform::wsl::fence::DrainRequest;
4439
4440        let (_local, shared, paths) = guest_paths();
4441        DrainRequest::new(4, chrono::Utc::now())
4442            .write(shared.path())
4443            .unwrap();
4444        let lock = WslRecoveryAllocationLock::new(paths, Arc::new(NoLock));
4445
4446        assert!(lock.acquire().await.is_err());
4447    }
4448
4449    #[tokio::test]
4450    async fn a_guest_launch_claim_is_released_with_its_allocation_guard() {
4451        use runner_manager_platform::wsl::fence::FENCE_DIRECTORY;
4452
4453        let (_local, shared, paths) = guest_paths();
4454        let lock = WslRecoveryAllocationLock::new(paths, Arc::new(NoLock));
4455        let guard = lock.acquire().await.unwrap();
4456        assert!(shared.path().join(FENCE_DIRECTORY).exists());
4457        drop(guard);
4458        assert!(!shared.path().join(FENCE_DIRECTORY).exists());
4459    }
4460
4461    /// Captures what `tracing` writes on this thread.
4462    #[derive(Clone, Default)]
4463    struct Logs(Arc<Mutex<Vec<u8>>>);
4464
4465    impl std::io::Write for Logs {
4466        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
4467            self.0.lock().unwrap().extend_from_slice(buf);
4468            Ok(buf.len())
4469        }
4470        fn flush(&mut self) -> std::io::Result<()> {
4471            Ok(())
4472        }
4473    }
4474
4475    /// The release that once failed with no trace while a distribution
4476    /// started nothing for 13 days is now in the log, with its path.
4477    #[tokio::test]
4478    async fn a_launch_fence_that_cannot_be_released_is_logged_with_its_path() {
4479        use runner_manager_platform::wsl::fence::FENCE_DIRECTORY;
4480
4481        let (_local, shared, paths) = guest_paths();
4482        let lock = WslRecoveryAllocationLock::new(paths, Arc::new(NoLock));
4483        let guard = lock.acquire().await.unwrap();
4484        // Something the removal cannot take away: a file where the claim
4485        // directory was.
4486        let fence = shared.path().join(FENCE_DIRECTORY);
4487        std::fs::remove_dir_all(&fence).unwrap();
4488        std::fs::write(&fence, b"not a directory").unwrap();
4489
4490        let logs = Logs::default();
4491        let writer = logs.clone();
4492        let subscriber = tracing_subscriber::fmt()
4493            .with_writer(move || writer.clone())
4494            .with_ansi(false)
4495            .finish();
4496        tracing::subscriber::with_default(subscriber, || drop(guard));
4497
4498        let text = String::from_utf8(logs.0.lock().unwrap().clone()).unwrap();
4499        assert!(text.contains("WARN"), "{text}");
4500        assert!(text.contains("could not be released"), "{text}");
4501        assert!(text.contains(FENCE_DIRECTORY), "the path is named: {text}");
4502    }
4503
4504    fn guest_paths() -> (
4505        tempfile::TempDir,
4506        tempfile::TempDir,
4507        Arc<runner_manager_platform::paths::AppPaths>,
4508    ) {
4509        use runner_manager_platform::paths::AppPaths;
4510        use runner_manager_platform::wsl::fence::GuestRecoveryConfig;
4511
4512        let local = tempfile::tempdir().unwrap();
4513        let shared = tempfile::tempdir().unwrap();
4514        let paths = Arc::new(AppPaths::rooted_at(local.path()));
4515        paths.create_all().unwrap();
4516        GuestRecoveryConfig::new(shared.path().to_path_buf())
4517            .write(&paths)
4518            .unwrap();
4519        (local, shared, paths)
4520    }
4521
4522    /// The incident end to end: a guest claim left by a daemon of an earlier
4523    /// WSL boot no longer stops a launch.
4524    #[tokio::test]
4525    async fn a_stale_guest_claim_no_longer_blocks_the_next_launch() {
4526        use runner_manager_platform::wsl::fence::{FENCE_DIRECTORY, OWNER_FILE};
4527
4528        let (_local, shared, paths) = guest_paths();
4529        let fence = shared.path().join(FENCE_DIRECTORY);
4530        std::fs::create_dir_all(&fence).unwrap();
4531        let pid = std::process::id();
4532        std::fs::write(
4533            fence.join(OWNER_FILE),
4534            format!(
4535                r#"{{"schema_version":1,"kind":"guest_launch","generation":null,"process_id":{pid},"acquired_at":"2026-09-24T00:47:18.724Z","identity":{{"pid":{pid},"start_token":"linux:earlier-boot:1"}}}}"#
4536            ),
4537        )
4538        .unwrap();
4539        let lock = WslRecoveryAllocationLock::new(paths, Arc::new(NoLock));
4540
4541        let guard = lock.acquire().await.expect("the stale claim was reclaimed");
4542        drop(guard);
4543        assert!(!fence.exists());
4544    }
4545
4546    #[tokio::test]
4547    async fn refusals_that_persist_are_recorded_and_a_grant_clears_them() {
4548        use runner_manager_platform::launch_health::recorded_launches_blocked;
4549        use runner_manager_platform::wsl::fence::{FenceClaim, FenceOwnerKind};
4550
4551        let (_local, shared, paths) = guest_paths();
4552        let lock = WslRecoveryAllocationLock::new(Arc::clone(&paths), Arc::new(NoLock))
4553            .with_blocked_after(3, Duration::ZERO);
4554        // Windows owns the fence, so every guest launch is refused.
4555        let windows =
4556            FenceClaim::try_claim(shared.path(), FenceOwnerKind::WindowsRecovery, Some(1))
4557                .unwrap()
4558                .unwrap();
4559
4560        for _ in 0..2 {
4561            assert!(lock.acquire().await.is_err());
4562        }
4563        assert_eq!(
4564            recorded_launches_blocked(&paths, chrono::Utc::now()).unwrap(),
4565            None,
4566            "not yet"
4567        );
4568        assert!(lock.acquire().await.is_err());
4569        let blocked = recorded_launches_blocked(&paths, chrono::Utc::now())
4570            .unwrap()
4571            .expect("three refusals in a row are recorded");
4572        assert!(
4573            blocked.reason.contains("Windows WSL recovery"),
4574            "{blocked:?}"
4575        );
4576
4577        windows.release().unwrap();
4578        let guard = lock.acquire().await.unwrap();
4579        assert_eq!(
4580            recorded_launches_blocked(&paths, chrono::Utc::now()).unwrap(),
4581            None
4582        );
4583        drop(guard);
4584    }
4585
4586    #[tokio::test]
4587    async fn refusals_while_this_process_is_launching_are_its_own_contention() {
4588        use runner_manager_platform::launch_health::recorded_launches_blocked;
4589
4590        use runner_manager_platform::wsl::fence::DrainRequest;
4591
4592        let (_local, shared, paths) = guest_paths();
4593        let lock = WslRecoveryAllocationLock::new(Arc::clone(&paths), Arc::new(NoLock))
4594            .with_blocked_after(1, Duration::ZERO);
4595        let launching = lock.acquire().await.unwrap();
4596        DrainRequest::new(1, chrono::Utc::now())
4597            .write(shared.path())
4598            .unwrap();
4599        for _ in 0..3 {
4600            assert!(lock.acquire().await.is_err());
4601        }
4602        assert_eq!(
4603            recorded_launches_blocked(&paths, chrono::Utc::now()).unwrap(),
4604            None
4605        );
4606        drop(launching);
4607    }
4608
4609    /// The launches of this process share the guest claim from reservation to
4610    /// runner: the other target's launch is not refused, and Windows gets the
4611    /// directory only when the last has ended.
4612    #[tokio::test]
4613    async fn launches_in_progress_share_the_fence_until_the_last_ends() {
4614        use runner_manager_platform::wsl::fence::{DrainRequest, FenceClaim, FenceOwnerKind};
4615
4616        let (_local, shared, paths) = guest_paths();
4617        let lock = WslRecoveryAllocationLock::new(paths, Arc::new(NoLock));
4618        let claimed = |root: &std::path::Path| FenceClaim::owner(root).unwrap().is_some();
4619
4620        let first = lock.acquire().await.unwrap();
4621        let first_runner = first.launch_hold();
4622        drop(first);
4623        assert!(claimed(shared.path()), "held while its runner comes up");
4624
4625        let claimed_at = FenceClaim::owner(shared.path())
4626            .unwrap()
4627            .unwrap()
4628            .acquired_at;
4629        tokio::time::sleep(Duration::from_millis(5)).await;
4630        let second = lock.acquire().await.expect("this process owns the fence");
4631        assert!(
4632            FenceClaim::owner(shared.path())
4633                .unwrap()
4634                .unwrap()
4635                .acquired_at
4636                > claimed_at,
4637            "a claim launches keep sharing is not one stuck for that long"
4638        );
4639        let second_runner = second.launch_hold();
4640        drop(second);
4641        drop(first_runner);
4642        assert!(claimed(shared.path()), "the other launch is still running");
4643
4644        // A drain stops new launches and lets the one in progress finish.
4645        DrainRequest::new(1, chrono::Utc::now())
4646            .write(shared.path())
4647            .unwrap();
4648        assert!(lock.acquire().await.is_err());
4649        assert!(claimed(shared.path()));
4650        assert!(
4651            FenceClaim::try_claim(shared.path(), FenceOwnerKind::WindowsRecovery, Some(1))
4652                .unwrap()
4653                .is_none()
4654        );
4655        drop(second_runner);
4656        assert!(!claimed(shared.path()), "the last one lets it go");
4657        assert!(
4658            FenceClaim::try_claim(shared.path(), FenceOwnerKind::WindowsRecovery, Some(1))
4659                .unwrap()
4660                .is_some()
4661        );
4662    }
4663
4664    /// Refusals separated by quiet spells -- brief contention days apart --
4665    /// never add up to a blocked host.
4666    #[tokio::test]
4667    async fn refusals_with_quiet_spells_between_them_never_add_up() {
4668        use runner_manager_platform::launch_health::recorded_launches_blocked;
4669        use runner_manager_platform::wsl::fence::{FenceClaim, FenceOwnerKind};
4670
4671        let (_local, shared, paths) = guest_paths();
4672        let lock = WslRecoveryAllocationLock::new(Arc::clone(&paths), Arc::new(NoLock))
4673            .with_blocked_after(3, Duration::ZERO)
4674            .with_streak_gap(Duration::ZERO);
4675        let windows =
4676            FenceClaim::try_claim(shared.path(), FenceOwnerKind::WindowsRecovery, Some(1))
4677                .unwrap()
4678                .unwrap();
4679        for _ in 0..5 {
4680            assert!(lock.acquire().await.is_err());
4681        }
4682        assert_eq!(
4683            recorded_launches_blocked(&paths, chrono::Utc::now()).unwrap(),
4684            None
4685        );
4686        windows.release().unwrap();
4687    }
4688
4689    /// A drain request refuses the launch after the claim was made; that
4690    /// claim is released rather than left to block the next pass.
4691    #[tokio::test]
4692    async fn a_claim_refused_by_a_drain_request_is_released() {
4693        use runner_manager_platform::wsl::fence::{DrainRequest, FENCE_DIRECTORY};
4694
4695        let (_local, shared, paths) = guest_paths();
4696        DrainRequest::new(4, chrono::Utc::now())
4697            .write(shared.path())
4698            .unwrap();
4699        let lock = WslRecoveryAllocationLock::new(paths, Arc::new(NoLock));
4700        assert!(lock.acquire().await.is_err());
4701        assert!(!shared.path().join(FENCE_DIRECTORY).exists());
4702    }
4703
4704    /// A lock nobody can take.
4705    #[derive(Debug, Default)]
4706    struct HeldLock;
4707
4708    #[async_trait::async_trait]
4709    impl AllocationLock for HeldLock {
4710        async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
4711            Err(AllocationLockBusy)
4712        }
4713    }
4714
4715    /// Everything one test needs, wired together.
4716    struct Harness {
4717        launcher: Arc<FakeLauncher>,
4718        demand: Arc<FakeDemand>,
4719        events: Arc<EventLog>,
4720        clock: Arc<FakeClock>,
4721        reconciler: Reconciler,
4722    }
4723
4724    impl Harness {
4725        fn build(
4726            host: Host,
4727            launcher: Arc<FakeLauncher>,
4728            demand: Arc<FakeDemand>,
4729            lock: Arc<dyn AllocationLock>,
4730        ) -> Self {
4731            let events = Arc::new(EventLog::new());
4732            let clock = Arc::new(FakeClock::default());
4733            let reconciler = Reconciler::new(
4734                host,
4735                ReconcilerPorts {
4736                    demand: Arc::clone(&demand) as Arc<dyn DemandSource>,
4737                    launcher: Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
4738                    lock,
4739                    directory: Arc::new(FakeDirectory::default()),
4740                    clock: Arc::clone(&clock) as Arc<dyn Clock>,
4741                    jitter: Arc::new(NoJitter) as Arc<dyn Jitter>,
4742                    events: Arc::clone(&events) as Arc<dyn EventSink>,
4743                },
4744            );
4745            Self {
4746                launcher,
4747                demand,
4748                events,
4749                clock,
4750                reconciler,
4751            }
4752        }
4753
4754        fn simple(capacity: u16, demand_count: u32, target: &str) -> Self {
4755            let launcher = Arc::new(FakeLauncher::new());
4756            let demand = Arc::new(FakeDemand::ready(demand_count, &repo(target)));
4757            Self::build(
4758                host_with(capacity),
4759                launcher,
4760                demand,
4761                Arc::new(InProcessAllocationLock::new()),
4762            )
4763        }
4764    }
4765
4766    fn attempt_in(state: AttemptState, id: u128, policy: u128) -> RunnerAttempt {
4767        let outcome = state.is_terminal().then(|| match state {
4768            AttemptState::Failed => {
4769                AttemptOutcome::failed(FailureReason::ProcessExitedUnexpectedly)
4770            }
4771            AttemptState::Orphaned => AttemptOutcome::Orphaned,
4772            _ => AttemptOutcome::CompletedJob,
4773        });
4774        RunnerAttempt::from_persisted(PersistedAttempt {
4775            id: AttemptId::from_u128(id),
4776            policy_id: PolicyId::from_u128(policy),
4777            github_runner_id: None,
4778            state,
4779            outcome,
4780            process_id: None,
4781            execution: runner_manager_domain::execution::AttemptExecution::Native {
4782                process_id: None,
4783            },
4784            runtime_path: "runtime/p/a".into(),
4785            workspace_kind: WorkspaceKind::Ephemeral,
4786            workspace_slot: None,
4787            created_at: fixtures::created_at(),
4788            terminal_at: state.is_terminal().then(fixtures::created_at),
4789            last_state_change_at: fixtures::created_at(),
4790        })
4791        .expect("a state/outcome pair the domain accepts")
4792    }
4793
4794    /// A concluded attempt carrying a specific outcome.
4795    fn concluded(id: u128, policy: u128, outcome: AttemptOutcome) -> RunnerAttempt {
4796        RunnerAttempt::from_persisted(PersistedAttempt {
4797            id: AttemptId::from_u128(id),
4798            policy_id: PolicyId::from_u128(policy),
4799            github_runner_id: None,
4800            state: outcome.terminal_state(),
4801            outcome: Some(outcome),
4802            process_id: None,
4803            execution: runner_manager_domain::execution::AttemptExecution::Native {
4804                process_id: None,
4805            },
4806            runtime_path: "runtime/p/a".into(),
4807            workspace_kind: WorkspaceKind::Ephemeral,
4808            workspace_slot: None,
4809            created_at: fixtures::created_at(),
4810            terminal_at: Some(fixtures::created_at()),
4811            last_state_change_at: fixtures::created_at(),
4812        })
4813        .expect("a state/outcome pair the domain accepts")
4814    }
4815
4816    // =======================================================================
4817    // The in-flight term: the single most likely way this task goes wrong
4818    // =======================================================================
4819
4820    /// `e1`'s Definition of Done, verbatim: *"A job that remains `queued` across
4821    /// three consecutive polls while its attempt is `starting` yields exactly
4822    /// one attempt — the test fails if the in-flight term is dropped from the
4823    /// formula."*
4824    ///
4825    /// `b1` tests the arithmetic underneath this
4826    /// (`capacity::tests::the_same_queued_job_on_two_polls_yields_one_attempt_
4827    /// not_two`). What *this* test covers is the only way `e1` can drop the
4828    /// term without touching `b1` at all: handing the allocator an attempt set
4829    /// that is not the one the host holds.
4830    ///
4831    /// # This was measured, not assumed, and the first measurement was worse
4832    /// # than the failure it was looking for
4833    ///
4834    /// Replacing `self.launcher.attempts().await` in
4835    /// [`Reconciler::start_runners`] with `Vec::new()` compiles and runs. Before
4836    /// that function carried a budget, this test did not go red — it **never
4837    /// returned**: every grant looked like the first, so the pass started
4838    /// runners forever inside poll 1. That is the runaway-runner failure exactly
4839    /// as an operator would meet it, and it is why the budget exists.
4840    ///
4841    /// With the budget in place the same injection fails cleanly and says what
4842    /// happened: `poll 2 … left: 2, right: 1`. Both measurements were run
4843    /// before this assertion was written.
4844    #[tokio::test]
4845    async fn three_polls_of_one_still_queued_run_yield_exactly_one_attempt() {
4846        let mut harness = Harness::simple(4, 1, "acme/app");
4847        let policy = policy(1, "acme/app", 4);
4848
4849        for poll in 1..=3 {
4850            let report = harness
4851                .reconciler
4852                .reconcile(std::slice::from_ref(&policy))
4853                .await;
4854            assert_eq!(
4855                harness.launcher.launches(),
4856                1,
4857                "poll {poll} started another runner for a job already being served; the \
4858                 `- active_owned_runners` term reached `HostAllocator` as a set this host \
4859                 does not hold"
4860            );
4861            assert_eq!(report.allocations.len(), 1);
4862            let allocation = &report.allocations[0];
4863            assert_eq!(allocation.demand, 1, "poll {poll}: still queued at GitHub");
4864            if poll == 1 {
4865                assert_eq!(allocation.to_start, 1);
4866                assert_eq!(report.started, 1);
4867            } else {
4868                assert_eq!(allocation.active_owned, 1, "poll {poll}");
4869                assert_eq!(allocation.to_start, 0, "poll {poll}");
4870                assert_eq!(report.started, 0, "poll {poll}");
4871            }
4872        }
4873        assert_eq!(harness.launcher.live_count(), 1);
4874    }
4875
4876    #[tokio::test]
4877    async fn draining_parallel_workflow_starts_runners_for_the_remaining_queue() {
4878        let mut harness = Harness::simple(10, 8, "acme/app");
4879        let policy = policy(1, "acme/app", 10);
4880        let first = harness
4881            .reconciler
4882            .reconcile(std::slice::from_ref(&policy))
4883            .await;
4884        assert_eq!(first.started, 8);
4885
4886        // Five jobs finish while three longer jobs keep their runners busy.
4887        let attempts = harness.launcher.snapshot();
4888        *harness.launcher.attempts.lock().unwrap() = attempts
4889            .iter()
4890            .take(3)
4891            .map(|attempt| attempt_in(AttemptState::Busy, attempt.id.as_uuid().as_u128(), 1))
4892            .collect();
4893        harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
4894            repo("acme/app"),
4895            jobs(2),
4896        )));
4897
4898        for poll in 0..3 {
4899            let report = harness
4900                .reconciler
4901                .reconcile(std::slice::from_ref(&policy))
4902                .await;
4903            assert_eq!(report.started, if poll == 0 { 2 } else { 0 });
4904            assert_eq!(harness.launcher.live_count(), 5);
4905        }
4906    }
4907
4908    /// The other half of the measurement above: the loop must terminate even
4909    /// when the attempt set never catches up with it.
4910    ///
4911    /// Dropping the in-flight term made
4912    /// `three_polls_of_one_still_queued_run_yield_exactly_one_attempt` hang
4913    /// rather than fail — the loop had one stopping condition and it was the one
4914    /// the bug removed. A launcher whose journal write has not landed presents
4915    /// exactly the same shape without any bug at all, so the budget in
4916    /// [`Reconciler::start_runners`] bounds the pass structurally. This is what
4917    /// asserts the bound is really there.
4918    #[tokio::test]
4919    async fn a_launcher_whose_attempts_never_appear_cannot_wedge_the_pass() {
4920        let launcher = Arc::new(FakeLauncher::forgetful());
4921        let mut harness = Harness::build(
4922            host_with(64),
4923            Arc::clone(&launcher),
4924            Arc::new(FakeDemand::ready(3, &repo("acme/app"))),
4925            Arc::new(InProcessAllocationLock::new()),
4926        );
4927
4928        let report = harness
4929            .reconciler
4930            .reconcile(&[policy(1, "acme/app", 8)])
4931            .await;
4932
4933        assert_eq!(
4934            report.started, 3,
4935            "the pass is bounded by the grant it was given, not by the attempt set catching \
4936             up with it"
4937        );
4938        assert_eq!(launcher.launches(), 3);
4939        assert!(
4940            launcher.snapshot().is_empty(),
4941            "the launcher never recorded anything, which is the whole point of the fixture"
4942        );
4943    }
4944
4945    /// The host-wide ceiling must hold across policies even when the launcher
4946    /// lags, and the per-policy budget alone does not reach that case.
4947    ///
4948    /// Review found this, with this file's own `forgetful` fixture and one more
4949    /// policy: the budget bounds *each policy's* loop to its own first grant,
4950    /// but policy B's first grant is computed from a set that does not yet
4951    /// contain policy A's launches, so B's bound is itself too large. Two
4952    /// policies on a host of three started **six** runners --
4953    /// `host_capacity=3, started=6, launches=6` -- with the lock held correctly
4954    /// throughout. Serialisation was never the problem; the arithmetic under it
4955    /// was reading a stale set.
4956    #[tokio::test]
4957    async fn two_policies_cannot_exceed_host_capacity_even_when_the_launcher_lags() {
4958        let launcher = Arc::new(FakeLauncher::forgetful());
4959        let demand = Arc::new(FakeDemand::default());
4960        demand.set_for(
4961            &ScaleTarget::repository("acme/left").unwrap(),
4962            PollOutcome::Ready(QueuedDemand::of(repo("acme/left"), jobs(3))),
4963        );
4964        demand.set_for(
4965            &ScaleTarget::repository("acme/right").unwrap(),
4966            PollOutcome::Ready(QueuedDemand::of(repo("acme/right"), jobs(3))),
4967        );
4968        let mut harness = Harness::build(
4969            host_with(3),
4970            Arc::clone(&launcher),
4971            demand,
4972            Arc::new(InProcessAllocationLock::new()),
4973        );
4974
4975        let report = harness
4976            .reconciler
4977            .reconcile(&[policy(1, "acme/left", 3), policy(2, "acme/right", 3)])
4978            .await;
4979
4980        assert_eq!(
4981            report.started, 3,
4982            "host_capacity is 3 and two policies each allowed 3 started {} runners \
4983             between them; the second policy's grant was computed from a set that did \
4984             not yet contain the first policy's launches",
4985            report.started
4986        );
4987        assert_eq!(launcher.launches(), 3);
4988    }
4989
4990    /// Finding 1: an attempt set that cannot be read is not an empty one.
4991    ///
4992    /// `attempts()` used to be infallible, which left `e3` — reading a journal
4993    /// off a disk — a choice between panicking and answering `vec![]`. The
4994    /// second is silent and catastrophic: an empty set is indistinguishable from
4995    /// an idle host, so a transient read failure reads as "nothing is running"
4996    /// and the pass allocates the whole machine for jobs already being served.
4997    ///
4998    /// The contrast is the assertion. Identical host, identical demand,
4999    /// identical policy; the only difference is whether the launcher can answer.
5000    #[tokio::test]
5001    async fn an_unreadable_attempt_set_starts_nothing_and_is_not_read_as_an_idle_host() {
5002        let launcher = Arc::new(FakeLauncher::new());
5003        let mut harness = Harness::build(
5004            host_with(8),
5005            Arc::clone(&launcher),
5006            Arc::new(FakeDemand::ready(4, &repo("acme/app"))),
5007            Arc::new(InProcessAllocationLock::new()),
5008        );
5009        let policy = policy(1, "acme/app", 8);
5010
5011        launcher.fail_attempts(true);
5012        let unreadable = harness
5013            .reconciler
5014            .reconcile(std::slice::from_ref(&policy))
5015            .await;
5016
5017        assert_eq!(
5018            unreadable.started, 0,
5019            "nothing may be decided from a set that was not read"
5020        );
5021        assert_eq!(launcher.launches(), 0);
5022        assert!(unreadable.attempts_unreadable > 0, "and the pass says so");
5023        assert!(
5024            unreadable.allocations.is_empty(),
5025            "no allocation is reported either: there was no set to compute one from, and \
5026             an allocation of zero would claim a decision nobody made"
5027        );
5028        assert!(harness.events.count_of("attempts_unreadable") > 0);
5029
5030        // The same everything, with a launcher that can answer.
5031        launcher.fail_attempts(false);
5032        let readable = harness
5033            .reconciler
5034            .reconcile(std::slice::from_ref(&policy))
5035            .await;
5036        assert_eq!(
5037            readable.started, 4,
5038            "the difference between the two passes is only whether the set could be read"
5039        );
5040        assert_eq!(readable.attempts_unreadable, 0);
5041    }
5042
5043    /// Finding 3: a cleanup that can never succeed was an invisible permanent
5044    /// loop.
5045    ///
5046    /// `if …clean(…).await.is_ok()` had no `else`, so a runtime directory that
5047    /// could not be removed was retried on every poll with no event, no counter
5048    /// and no report field. It wedges no capacity — a terminal attempt already
5049    /// stopped counting — but `clean` returns a `Result` precisely so the caller
5050    /// can say something, and this module's organising principle is the things
5051    /// that go wrong silently.
5052    #[tokio::test]
5053    async fn a_cleanup_that_cannot_succeed_is_reported_rather_than_retried_in_silence() {
5054        let launcher = Arc::new(FakeLauncher::refusing_cleanup(vec![concluded(
5055            1,
5056            1,
5057            AttemptOutcome::ExitedIdleWithoutWork,
5058        )]));
5059        let mut harness = Harness::build(
5060            host_with(4),
5061            Arc::clone(&launcher),
5062            Arc::new(FakeDemand::ready(0, &repo("acme/app"))),
5063            Arc::new(InProcessAllocationLock::new()),
5064        );
5065
5066        let report = harness
5067            .reconciler
5068            .reconcile(&[policy(1, "acme/app", 4)])
5069            .await;
5070
5071        assert_eq!(report.cleaned, 0);
5072        assert_eq!(report.clean_failures, 1);
5073        assert_eq!(harness.events.count_of("attempt_clean_failed"), 1);
5074        assert_eq!(
5075            harness.events.count_of("attempt_cleaned"),
5076            0,
5077            "and it is not reported as cleaned"
5078        );
5079        assert_eq!(
5080            launcher.snapshot().len(),
5081            1,
5082            "the attempt is still there, so the retry is real -- what changed is that it \
5083             is no longer silent"
5084        );
5085
5086        // The reason is the variant, never the detail: the fixture's failure
5087        // carries free text and none of it reaches the event.
5088        let reasons: Vec<&'static str> = harness
5089            .events
5090            .events()
5091            .into_iter()
5092            .filter_map(|event| match event {
5093                LifecycleEvent::AttemptCleanFailed { reason, .. } => Some(reason),
5094                _ => None,
5095            })
5096            .collect();
5097        assert_eq!(reasons, vec!["other"]);
5098    }
5099
5100    /// A cleanup that keeps failing is retried with per-attempt exponential
5101    /// backoff rather than on every pass, and a success forgets the backoff.
5102    #[tokio::test]
5103    async fn a_failing_cleanup_backs_off_exponentially_per_attempt() {
5104        let launcher = Arc::new(FakeLauncher::refusing_cleanup(vec![concluded(
5105            1,
5106            1,
5107            AttemptOutcome::ExitedIdleWithoutWork,
5108        )]));
5109        let mut harness = Harness::build(
5110            host_with(4),
5111            Arc::clone(&launcher),
5112            Arc::new(FakeDemand::ready(0, &repo("acme/app"))),
5113            Arc::new(InProcessAllocationLock::new()),
5114        );
5115        let policies = [policy(1, "acme/app", 4)];
5116        let start = harness.clock.now();
5117        let at = |offset: Duration| start + chrono::Duration::from_std(offset).unwrap();
5118        let mut failures_at = async |offset: Duration| {
5119            harness.clock.set(at(offset));
5120            harness.reconciler.reconcile(&policies).await.clean_failures
5121        };
5122
5123        assert_eq!(failures_at(Duration::ZERO).await, 1);
5124        // Inside the first delay: not tried, so nothing new to report.
5125        assert_eq!(
5126            failures_at(CLEAN_RETRY_INITIAL - Duration::from_secs(1)).await,
5127            0,
5128            "a failed cleanup was retried on the very next pass"
5129        );
5130        assert_eq!(failures_at(CLEAN_RETRY_INITIAL).await, 1);
5131        // The second delay is twice the first.
5132        let second = CLEAN_RETRY_INITIAL + CLEAN_RETRY_INITIAL * 2;
5133        assert_eq!(
5134            failures_at(second - Duration::from_secs(1)).await,
5135            0,
5136            "the delay did not double after a second failure"
5137        );
5138        assert_eq!(failures_at(second).await, 1);
5139        assert_eq!(harness.events.count_of("attempt_clean_failed"), 3);
5140
5141        // The delay never exceeds the ceiling, however many failures accrue.
5142        let mut backoff = CleanBackoff::default();
5143        let id = AttemptId::from_u128(9);
5144        for _ in 0..40 {
5145            backoff.failed(id, start);
5146        }
5147        assert!(!backoff.is_due(id, at(CLEAN_RETRY_MAXIMUM - Duration::from_secs(1))));
5148        assert!(backoff.is_due(id, at(CLEAN_RETRY_MAXIMUM)));
5149        backoff.succeeded(id);
5150        assert!(
5151            backoff.is_due(id, start),
5152            "a cleaned attempt kept its backoff"
5153        );
5154    }
5155
5156    /// N2: an unreadable attempt set makes a scale-down inconclusive, not empty.
5157    ///
5158    /// Making `attempts()` fallible closed this everywhere the allocation path
5159    /// touches, and left it open in the one place that returns a different type:
5160    /// `scale_down` answered `ScaleDownReport::default()`, which is all zeros
5161    /// and byte-for-byte identical to an idle host with nothing to reclaim. The
5162    /// two mean opposite things — "there was nothing to remove" against "we
5163    /// cannot see what there was".
5164    ///
5165    /// Measured as the sibling test measures it: identical host, identical
5166    /// attempts, identical policy, and the only difference is whether the
5167    /// launcher can answer.
5168    #[tokio::test]
5169    async fn an_unreadable_attempt_set_makes_scale_down_inconclusive_rather_than_empty() {
5170        let launcher = Arc::new(FakeLauncher::new().seeded(vec![
5171            attempt_in(AttemptState::Busy, 1, 1),
5172            concluded(2, 1, AttemptOutcome::CompletedJob),
5173        ]));
5174        let harness = Harness::build(
5175            host_with(4),
5176            Arc::clone(&launcher),
5177            Arc::new(FakeDemand::ready(0, &repo("acme/app"))),
5178            Arc::new(InProcessAllocationLock::new()),
5179        );
5180        let policy = policy(1, "acme/app", 4);
5181
5182        launcher.fail_attempts(true);
5183        let blind = harness.reconciler.scale_down(&policy).await;
5184
5185        assert!(!blind.is_conclusive(), "the machine was never read");
5186        assert_ne!(
5187            blind,
5188            ScaleDownReport::default(),
5189            "a scale-down that could not see the host must not be equal to one that saw \
5190             an idle host; that equality is the whole finding"
5191        );
5192        assert_eq!(blind.removed, 0);
5193        assert_eq!(
5194            blind.refused_busy, 0,
5195            "and this zero means `unknown`, not `none`"
5196        );
5197        assert_eq!(harness.events.count_of("attempts_unreadable"), 1);
5198
5199        // The same everything, with a launcher that can answer.
5200        launcher.fail_attempts(false);
5201        let seeing = harness.reconciler.scale_down(&policy).await;
5202
5203        assert!(seeing.is_conclusive());
5204        assert_eq!(seeing.removed, 1, "the concluded attempt was reclaimed");
5205        assert_eq!(seeing.refused_busy, 1, "and the busy one was left alone");
5206        assert_ne!(
5207            seeing, blind,
5208            "the difference between the two is only whether the set could be read"
5209        );
5210    }
5211
5212    /// Finding 7: the lower arm of the clamp, driven through the reconciler.
5213    ///
5214    /// `demand_below_min_capacity_starts_nothing_in_v1` runs `demand = 0`
5215    /// against `min_capacity = 0`, which is *at* the floor and never raises
5216    /// `desired` — the assertion held for a reason unrelated to the boundary it
5217    /// named. D7 fixes `min` at 0 for v1, but `AutoscaleConfig::new` accepts
5218    /// `min > 0` today, so the path is representable and was undriven.
5219    #[tokio::test]
5220    async fn demand_below_min_capacity_is_raised_to_min_capacity() {
5221        let mut warm = ScalePolicy::new(
5222            PolicyId::from_u128(1),
5223            ScaleTarget::repository("acme/app").unwrap(),
5224            1,
5225            fixtures::HOST_ID,
5226            PolicyMode::autoscale(
5227                fixtures::routing_labels("home"),
5228                2,
5229                NonZeroU16::new(5).expect("non-zero"),
5230            )
5231            .expect("min <= max"),
5232            CachePolicy::default(),
5233        );
5234        warm.activate().expect("pending -> active");
5235
5236        let mut harness = Harness::simple(8, 0, "acme/app");
5237        let report = harness.reconciler.reconcile(&[warm]).await;
5238
5239        assert_eq!(
5240            report.allocations[0].demand, 0,
5241            "GitHub reported no queued runs"
5242        );
5243        assert_eq!(
5244            report.allocations[0].desired, 2,
5245            "min_capacity raised the target above demand"
5246        );
5247        assert_eq!(
5248            report.allocations[0].limiting_factor,
5249            LimitingFactor::MinCapacity
5250        );
5251        assert_eq!(report.started, 2, "and two runners were actually started");
5252        assert_eq!(harness.launcher.live_count(), 2);
5253    }
5254
5255    // =======================================================================
5256    // Memory admission
5257    // =======================================================================
5258
5259    /// The domain rule over a fixed free-memory reading: every attempt on the
5260    /// host is estimated at `estimate` and none has been sampled yet.
5261    #[derive(Debug)]
5262    struct FixedMemory {
5263        free: u64,
5264        estimate: u64,
5265        admitted: std::sync::atomic::AtomicU32,
5266    }
5267
5268    impl MemoryGate for FixedMemory {
5269        fn admit(&self, _policy: &ScalePolicy, attempts: &[RunnerAttempt]) -> MemoryVerdict {
5270            let running: Vec<runner_manager_domain::memory::Running> = attempts
5271                .iter()
5272                .filter(|attempt| attempt.counts_against_capacity())
5273                .map(|_| runner_manager_domain::memory::Running {
5274                    estimate: self.estimate,
5275                    current_rss: None,
5276                })
5277                .collect();
5278            runner_manager_domain::memory::admit(
5279                Some(self.free),
5280                &running,
5281                runner_manager_domain::memory::Estimate::choose(None, Some(self.estimate)),
5282                GIB,
5283            )
5284        }
5285
5286        fn admitted(&self, _policy: &ScalePolicy) {
5287            self.admitted
5288                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5289        }
5290    }
5291
5292    const GIB: u64 = 1 << 30;
5293
5294    fn with_memory(harness: Harness, free: u64, estimate: u64) -> (Harness, Arc<FixedMemory>) {
5295        let gate = Arc::new(FixedMemory {
5296            free,
5297            estimate,
5298            admitted: std::sync::atomic::AtomicU32::new(0),
5299        });
5300        let Harness {
5301            launcher,
5302            demand,
5303            events,
5304            clock,
5305            reconciler,
5306        } = harness;
5307        let reconciler = reconciler.with_memory_gate(Arc::clone(&gate) as Arc<dyn MemoryGate>);
5308        (
5309            Harness {
5310                launcher,
5311                demand,
5312                events,
5313                clock,
5314                reconciler,
5315            },
5316            gate,
5317        )
5318    }
5319
5320    #[tokio::test]
5321    async fn a_start_the_memory_cannot_hold_is_deferred_and_the_demand_kept() {
5322        // 10 GiB free, 4 GiB per runner, a 1 GiB margin: the first fits whole,
5323        // the second fits beside the first's 4 GiB still to come, the third
5324        // does not. Every ceiling would have granted five.
5325        let (mut harness, gate) = with_memory(Harness::simple(8, 5, "acme/app"), 10 * GIB, 4 * GIB);
5326        let report = harness
5327            .reconciler
5328            .reconcile(&[policy(1, "acme/app", 5)])
5329            .await;
5330
5331        assert_eq!(
5332            report.allocations[0].to_start, 5,
5333            "the ceilings granted five"
5334        );
5335        assert_eq!(report.started, 2);
5336        assert_eq!(report.memory_deferred, 3, "deferred, not failed");
5337        assert_eq!(report.failures, 0);
5338        assert_eq!(gate.admitted.load(std::sync::atomic::Ordering::Relaxed), 2);
5339        let deferred: Vec<MemoryFigures> = harness
5340            .events
5341            .events()
5342            .into_iter()
5343            .filter_map(|event| match event {
5344                LifecycleEvent::MemoryShort {
5345                    figures,
5346                    started: false,
5347                    ..
5348                } => Some(figures),
5349                _ => None,
5350            })
5351            .collect();
5352        assert_eq!(deferred.len(), 1);
5353        assert_eq!(deferred[0].free, 10 * GIB);
5354        assert_eq!(deferred[0].reserved, 8 * GIB);
5355        assert_eq!(deferred[0].needed(), 5 * GIB);
5356
5357        // The demand is still queued: the next pass asks again, and is
5358        // refused again while nothing has ended.
5359        let again = harness
5360            .reconciler
5361            .reconcile(&[policy(1, "acme/app", 5)])
5362            .await;
5363        assert_eq!(again.started, 0);
5364        assert!(again.memory_deferred > 0);
5365        assert_eq!(harness.launcher.live_count(), 2);
5366    }
5367
5368    #[tokio::test]
5369    async fn a_runner_bigger_than_the_free_memory_starts_when_nothing_else_runs() {
5370        let (mut harness, _) = with_memory(Harness::simple(8, 2, "acme/app"), GIB, 4 * GIB);
5371        let report = harness
5372            .reconciler
5373            .reconcile(&[policy(1, "acme/app", 5)])
5374            .await;
5375
5376        assert_eq!(report.started, 1, "no starvation, but only one");
5377        assert_eq!(report.memory_deferred, 1);
5378        let names: Vec<&str> = harness
5379            .events
5380            .events()
5381            .iter()
5382            .map(LifecycleEvent::name)
5383            .filter(|name| name.starts_with("memory_"))
5384            .collect();
5385        assert_eq!(names, ["memory_admitted_alone", "memory_deferred"]);
5386    }
5387
5388    #[tokio::test]
5389    async fn without_a_memory_gate_every_granted_runner_starts() {
5390        let mut harness = Harness::simple(8, 5, "acme/app");
5391        let report = harness
5392            .reconciler
5393            .reconcile(&[policy(1, "acme/app", 5)])
5394            .await;
5395        assert_eq!(report.started, 5);
5396        assert_eq!(report.memory_deferred, 0);
5397    }
5398
5399    // =======================================================================
5400    // Capacity, at the boundaries
5401    // =======================================================================
5402
5403    #[tokio::test]
5404    async fn demand_above_max_capacity_is_clamped_to_max_capacity() {
5405        let mut harness = Harness::simple(100, 10, "acme/app");
5406        let report = harness
5407            .reconciler
5408            .reconcile(&[policy(1, "acme/app", 3)])
5409            .await;
5410
5411        assert_eq!(report.allocations[0].demand, 10);
5412        assert_eq!(
5413            report.allocations[0].desired, 3,
5414            "max_capacity beats demand"
5415        );
5416        assert_eq!(report.started, 3);
5417        assert_eq!(
5418            report.allocations[0].limiting_factor,
5419            LimitingFactor::MaxCapacity
5420        );
5421    }
5422
5423    #[tokio::test]
5424    async fn demand_below_min_capacity_starts_nothing_in_v1() {
5425        // D7 fixes `min_capacity` at 0, so "below the floor" is "no demand", and
5426        // the product requirement it satisfies is "no idle runners when unused".
5427        let mut harness = Harness::simple(8, 0, "acme/app");
5428        let report = harness
5429            .reconciler
5430            .reconcile(&[policy(1, "acme/app", 4)])
5431            .await;
5432
5433        assert_eq!(report.allocations[0].desired, 0);
5434        assert_eq!(report.started, 0);
5435        assert!(report.starts_nothing());
5436    }
5437
5438    #[tokio::test]
5439    async fn lifecycle_replacement_intent_is_consumed_by_the_ordinary_allocator() {
5440        let policy = policy(1, "octo/repo", 1);
5441        let previous = attempt_in(AttemptState::Starting, 41, 1);
5442        let intent = ReplacementIntent {
5443            policy: policy.id,
5444            previous_attempt: previous.id,
5445            operation: "exit_before_acceptance_replacement",
5446        };
5447        let launcher = Arc::new(FakeLauncher::new().seeded(vec![previous]).replacing(intent));
5448        let demand = Arc::new(FakeDemand::ready(1, &repo("octo/repo")));
5449        let mut harness = Harness::build(
5450            host_with(1),
5451            Arc::clone(&launcher),
5452            demand,
5453            Arc::new(InProcessAllocationLock::new()),
5454        );
5455
5456        let report = harness.reconciler.reconcile(&[policy]).await;
5457
5458        assert_eq!(report.replacement_intents, 1);
5459        assert_eq!(report.started, 1);
5460        assert_eq!(launcher.launches(), 1);
5461        assert_eq!(launcher.live_count(), 1);
5462    }
5463
5464    #[tokio::test]
5465    async fn zero_host_headroom_starts_nothing_at_maximum_demand() {
5466        let launcher = Arc::new(FakeLauncher::new().seeded(vec![
5467            attempt_in(AttemptState::Busy, 1, 1),
5468            attempt_in(AttemptState::Busy, 2, 1),
5469        ]));
5470        let demand = Arc::new(FakeDemand::ready(u32::from(u16::MAX), &repo("acme/app")));
5471        let mut harness = Harness::build(
5472            host_with(2),
5473            launcher,
5474            demand,
5475            Arc::new(InProcessAllocationLock::new()),
5476        );
5477
5478        let report = harness
5479            .reconciler
5480            .reconcile(&[policy(1, "acme/app", 2)])
5481            .await;
5482        assert_eq!(report.started, 0);
5483        assert_eq!(report.allocations[0].headroom_before, 0);
5484        assert_eq!(harness.launcher.launches(), 0);
5485    }
5486
5487    #[tokio::test]
5488    async fn headroom_smaller_than_the_per_policy_allowance_wins() {
5489        // Four slots held by *another* policy on a host of six: this policy is
5490        // allowed five and gets two.
5491        let launcher = Arc::new(FakeLauncher::new().seeded(vec![
5492            attempt_in(AttemptState::Busy, 1, 99),
5493            attempt_in(AttemptState::Busy, 2, 99),
5494            attempt_in(AttemptState::Idle, 3, 99),
5495            attempt_in(AttemptState::Starting, 4, 99),
5496        ]));
5497        let demand = Arc::new(FakeDemand::ready(5, &repo("acme/app")));
5498        let mut harness = Harness::build(
5499            host_with(6),
5500            launcher,
5501            demand,
5502            Arc::new(InProcessAllocationLock::new()),
5503        );
5504
5505        let report = harness
5506            .reconciler
5507            .reconcile(&[policy(1, "acme/app", 5)])
5508            .await;
5509        assert_eq!(
5510            report.allocations[0].desired, 5,
5511            "its own ceiling allows five"
5512        );
5513        assert_eq!(report.started, 2, "the host has two slots free");
5514        assert_eq!(
5515            report.allocations[0].limiting_factor,
5516            LimitingFactor::HostCapacity
5517        );
5518        assert_eq!(harness.launcher.live_count(), 6);
5519    }
5520
5521    #[tokio::test]
5522    async fn the_idle_host_assertion_holds() {
5523        // "No demand means zero runner processes and zero attempts out of
5524        // terminal state."
5525        let mut harness = Harness::simple(8, 0, "acme/app");
5526        let report = harness
5527            .reconciler
5528            .reconcile(&[policy(1, "acme/app", 4), policy(2, "acme/app", 4)])
5529            .await;
5530
5531        assert_eq!(report.started, 0);
5532        assert_eq!(harness.launcher.launches(), 0);
5533        assert!(harness.launcher.snapshot().is_empty());
5534        assert_eq!(
5535            harness
5536                .launcher
5537                .snapshot()
5538                .iter()
5539                .filter(|a| !a.is_terminal())
5540                .count(),
5541            0
5542        );
5543    }
5544
5545    // =======================================================================
5546    // Live host capacity, including changes while a pass is creating runners.
5547    // =======================================================================
5548
5549    #[tokio::test]
5550    async fn live_capacity_increase_is_used_without_rebuilding_the_reconciler() {
5551        let mut harness = Harness::simple(1, 5, "acme/app");
5552        let policies = [policy(1, "acme/app", 5)];
5553        assert_eq!(harness.reconciler.reconcile(&policies).await.started, 1);
5554
5555        *harness.launcher.capacity.lock().unwrap() = NonZeroU16::new(4);
5556        assert_eq!(harness.reconciler.reconcile(&policies).await.started, 3);
5557        assert_eq!(harness.launcher.live_count(), 4);
5558    }
5559
5560    #[tokio::test]
5561    async fn live_capacity_decrease_keeps_busy_runners_and_waits_for_headroom() {
5562        let mut harness = Harness::simple(5, 5, "acme/app");
5563        let busy: Vec<_> = (1..=3)
5564            .map(|id| attempt_in(AttemptState::Busy, id, 1))
5565            .collect();
5566        *harness.launcher.attempts.lock().unwrap() = busy.clone();
5567        *harness.launcher.capacity.lock().unwrap() = NonZeroU16::new(2);
5568        let policies = [policy(1, "acme/app", 5)];
5569
5570        assert_eq!(harness.reconciler.reconcile(&policies).await.started, 0);
5571        assert_eq!(harness.launcher.snapshot(), busy);
5572        assert!(harness.launcher.cleaned().is_empty());
5573
5574        // Two jobs finish; one busy job still owns a slot.
5575        harness.launcher.attempts.lock().unwrap().truncate(1);
5576        assert_eq!(harness.reconciler.reconcile(&policies).await.started, 1);
5577        assert_eq!(harness.launcher.live_count(), 2);
5578    }
5579
5580    #[tokio::test]
5581    async fn live_capacity_is_rechecked_between_launches_in_the_same_pass() {
5582        let mut harness = Harness::simple(5, 5, "acme/app");
5583        *harness.launcher.capacity_after_launch.lock().unwrap() = NonZeroU16::new(1);
5584
5585        let report = harness
5586            .reconciler
5587            .reconcile(&[policy(1, "acme/app", 5)])
5588            .await;
5589        assert_eq!(report.started, 1);
5590        assert_eq!(harness.launcher.live_count(), 1);
5591    }
5592
5593    #[tokio::test]
5594    async fn live_capacity_unreadable_blocks_launches_until_the_journal_recovers() {
5595        let mut harness = Harness::simple(5, 5, "acme/app");
5596        harness
5597            .launcher
5598            .capacity_unreadable
5599            .store(true, Ordering::SeqCst);
5600        let policies = [policy(1, "acme/app", 5)];
5601        let report = harness.reconciler.reconcile(&policies).await;
5602        assert_eq!(report.started, 0);
5603        assert_eq!(report.host_capacity_unreadable, 1);
5604        assert_eq!(report.attempts_unreadable, 0);
5605        assert_eq!(harness.events.count_of("attempts_unreadable"), 0);
5606        assert!(
5607            harness
5608                .events
5609                .events()
5610                .contains(&LifecycleEvent::HostCapacityUnreadable {
5611                    reason: HostCapacityError::JournalUnreadable,
5612                })
5613        );
5614
5615        harness
5616            .launcher
5617            .capacity_unreadable
5618            .store(false, Ordering::SeqCst);
5619        *harness.launcher.capacity.lock().unwrap() = NonZeroU16::new(2);
5620        assert_eq!(harness.reconciler.reconcile(&policies).await.started, 2);
5621    }
5622
5623    #[tokio::test]
5624    async fn unreadable_local_state_does_not_treat_a_busy_host_as_idle() {
5625        for host_failure in [true, false] {
5626            let mut harness = Harness::simple(5, 0, "acme/app");
5627            harness
5628                .reconciler
5629                .set_intervals(RefreshInterval::default(), Duration::from_secs(10));
5630            *harness.launcher.attempts.lock().unwrap() = vec![attempt_in(AttemptState::Busy, 1, 1)];
5631            harness
5632                .launcher
5633                .capacity_unreadable
5634                .store(host_failure, Ordering::SeqCst);
5635            harness.launcher.fail_attempts(!host_failure);
5636            let report = harness
5637                .reconciler
5638                .reconcile(&[policy(1, "acme/app", 5)])
5639                .await;
5640            assert_eq!(report.host_capacity_unreadable, u16::from(host_failure));
5641            assert_eq!(report.next_poll.pace, PollPace::Nominal);
5642            assert_eq!(harness.launcher.live_count(), 1);
5643        }
5644    }
5645
5646    #[derive(Debug)]
5647    struct HostEditOnLock {
5648        inner: InProcessAllocationLock,
5649        launcher: Arc<FakeLauncher>,
5650        unreadable: bool,
5651    }
5652
5653    #[async_trait::async_trait]
5654    impl AllocationLock for HostEditOnLock {
5655        async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
5656            let guard = self.inner.acquire().await?;
5657            *self.launcher.capacity.lock().unwrap() = NonZeroU16::new(1);
5658            self.launcher
5659                .capacity_unreadable
5660                .store(self.unreadable, Ordering::SeqCst);
5661            Ok(guard)
5662        }
5663    }
5664
5665    #[tokio::test]
5666    async fn live_capacity_edits_and_read_failures_while_waiting_for_the_lock_block_launches() {
5667        for unreadable in [false, true] {
5668            let launcher =
5669                Arc::new(FakeLauncher::new().seeded(vec![attempt_in(AttemptState::Busy, 1, 1)]));
5670            let lock = Arc::new(HostEditOnLock {
5671                inner: InProcessAllocationLock::new(),
5672                launcher: Arc::clone(&launcher),
5673                unreadable,
5674            });
5675            let mut harness = Harness::build(
5676                host_with(5),
5677                Arc::clone(&launcher),
5678                Arc::new(FakeDemand::ready(5, &repo("acme/app"))),
5679                lock,
5680            );
5681            let report = harness
5682                .reconciler
5683                .reconcile(&[policy(1, "acme/app", 5)])
5684                .await;
5685            assert_eq!(report.started, 0);
5686            assert_eq!(launcher.live_count(), 1);
5687            assert!(launcher.cleaned().is_empty());
5688            assert_eq!(report.host_capacity_unreadable, u16::from(unreadable));
5689            assert_eq!(report.attempts_unreadable, 0);
5690        }
5691    }
5692
5693    // =======================================================================
5694    // D9 under concurrency: the other silent failure
5695    // =======================================================================
5696
5697    /// `e1`'s Definition of Done: *"Two policies on one host with
5698    /// `host_capacity` smaller than the sum of their `max_capacity` values never
5699    /// exceed `host_capacity` under concurrent reconciliation — asserted under
5700    /// simulated lock contention, with no duplicate runners."*
5701    ///
5702    /// The contention is simulated by [`FakeLauncher::with_yields`], which puts
5703    /// executor yield points *between* the launcher reading the attempt set and
5704    /// recording the new one. Without serialisation both tasks read a headroom
5705    /// of three and both spend it.
5706    ///
5707    /// # Watched failing before it was made to pass
5708    ///
5709    /// Granting from this lock without taking the inner mutex — leaving every
5710    /// counter and every yield point exactly as they are — fails this assertion
5711    /// with `left: 4, right: 3`: four runners on a host of three, from two
5712    /// policies each individually inside their own `max_capacity`. The control
5713    /// test below keeps that measurement standing permanently by running the
5714    /// same body against [`NoLock`].
5715    #[tokio::test(flavor = "current_thread")]
5716    async fn two_policies_reconciling_concurrently_never_exceed_host_capacity() {
5717        let lock = Arc::new(CountingLock::new());
5718        let (launches, live) =
5719            two_policies_concurrently(Arc::clone(&lock) as Arc<dyn AllocationLock>, None).await;
5720
5721        assert_eq!(
5722            launches, 3,
5723            "the sum across policies must never exceed host_capacity, and each policy is \
5724             individually within its own max_capacity of 3"
5725        );
5726        assert_eq!(live, 3, "and no duplicate runner survived the race");
5727        assert_eq!(
5728            lock.peak(),
5729            1,
5730            "the allocation lock had one holder at a time; without that the read of the \
5731             headroom and the creation of the runtime are not atomic"
5732        );
5733        assert!(
5734            (3..=5).contains(&lock.acquisitions()),
5735            "the lock is taken before *each* runtime, not once per pass: three runtimes \
5736             means at least three holds, and at most one further hold per policy to \
5737             discover the host filled up underneath it. It was taken {} times",
5738            lock.acquisitions()
5739        );
5740    }
5741
5742    /// The control for the test above: the same body with the lock removed.
5743    ///
5744    /// It exists so that the assertion above cannot pass vacuously. If a future
5745    /// change makes the unserialised path safe by accident — a launcher that
5746    /// records synchronously, say — this test goes red and says so, rather than
5747    /// the other one silently proving nothing.
5748    #[tokio::test(flavor = "current_thread")]
5749    async fn without_the_allocation_lock_two_policies_oversubscribe_the_host() {
5750        let (launches, _) =
5751            two_policies_concurrently(Arc::new(NoLock) as Arc<dyn AllocationLock>, None).await;
5752
5753        assert!(
5754            launches > 3,
5755            "with no serialisation both policies must be able to spend the same headroom; \
5756             they started {launches} runners on a host of 3. If this is ever 3, the \
5757             contention window closed and `two_policies_reconciling_concurrently_never_\
5758             exceed_host_capacity` has stopped proving anything"
5759        );
5760    }
5761
5762    /// Two policies, one host of three, each allowed three, reconciled at once.
5763    ///
5764    /// Returns `(launches, live attempts)`.
5765    async fn two_policies_concurrently(
5766        lock: Arc<dyn AllocationLock>,
5767        live_capacity: Option<NonZeroU16>,
5768    ) -> (usize, usize) {
5769        let launcher = Arc::new(FakeLauncher::new().with_yields(4));
5770        *launcher.capacity.lock().unwrap() = live_capacity;
5771        let host = host_with(3);
5772
5773        let mut left = Harness::build(
5774            host.clone(),
5775            Arc::clone(&launcher),
5776            Arc::new(FakeDemand::ready(3, &repo("acme/left"))),
5777            Arc::clone(&lock),
5778        )
5779        .reconciler;
5780        let mut right = Harness::build(
5781            host,
5782            Arc::clone(&launcher),
5783            Arc::new(FakeDemand::ready(3, &repo("acme/right"))),
5784            Arc::clone(&lock),
5785        )
5786        .reconciler;
5787
5788        let a = policy(1, "acme/left", 3);
5789        let b = policy(2, "acme/right", 3);
5790
5791        let left = tokio::spawn(async move { left.reconcile(&[a]).await });
5792        let right = tokio::spawn(async move { right.reconcile(&[b]).await });
5793        let (_, _) = (left.await.unwrap(), right.await.unwrap());
5794
5795        (launcher.launches(), launcher.live_count())
5796    }
5797
5798    #[tokio::test(flavor = "current_thread")]
5799    async fn live_capacity_is_shared_by_concurrent_targets_with_stale_startup_hosts() {
5800        let (launches, live) =
5801            two_policies_concurrently(Arc::new(InProcessAllocationLock::new()), NonZeroU16::new(2))
5802                .await;
5803        assert_eq!(launches, 2);
5804        assert_eq!(live, 2);
5805    }
5806
5807    // =======================================================================
5808    // D19: monitor-only
5809    // =======================================================================
5810
5811    /// `e1`'s Definition of Done: *"A `MonitorOnly` policy under maximum demand
5812    /// starts zero runners and issues no demand request."*
5813    ///
5814    /// Driven through `c4`'s real gateway fake so that "issued no demand
5815    /// request" is asserted against the thing that would have issued it, rather
5816    /// than against this module's own bookkeeping. `FakeGithub` records every
5817    /// call it is asked to make.
5818    #[tokio::test]
5819    async fn a_monitor_only_policy_under_maximum_demand_starts_nothing_and_polls_nothing() {
5820        let gateway = FakeGithub::new().with_queued_jobs(repo("acme/app"), jobs(10_000));
5821        let gateway = Arc::new(GatewayDemand::new(gateway, CancelToken::new()));
5822        let launcher = Arc::new(FakeLauncher::new());
5823        let events = Arc::new(EventLog::new());
5824
5825        let mut reconciler = Reconciler::new(
5826            host_with(10),
5827            ReconcilerPorts {
5828                demand: Arc::clone(&gateway) as Arc<dyn DemandSource>,
5829                launcher: Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
5830                lock: Arc::new(InProcessAllocationLock::new()),
5831                directory: Arc::new(FakeDirectory::default()),
5832                clock: Arc::new(FakeClock::default()),
5833                jitter: Arc::new(NoJitter),
5834                events: Arc::clone(&events) as Arc<dyn EventSink>,
5835            },
5836        );
5837
5838        let monitor = fixtures::policy()
5839            .id(PolicyId::from_u128(1))
5840            .repository("acme/app")
5841            .monitor_only()
5842            .active()
5843            .build();
5844
5845        let report = reconciler.reconcile(&[monitor]).await;
5846
5847        assert_eq!(report.started, 0);
5848        assert_eq!(launcher.launches(), 0);
5849        assert_eq!(report.monitor_only, vec![PolicyId::from_u128(1)]);
5850        assert_eq!(
5851            report.demand_requests, 0,
5852            "a monitor-only policy spends nothing from the shared hourly ceiling"
5853        );
5854        assert!(
5855            gateway.gateway().calls().is_empty(),
5856            "a monitor-only policy issued a demand request: {:?}",
5857            gateway.gateway().calls()
5858        );
5859        assert_eq!(events.count_of("monitor_only_skipped"), 1);
5860        assert_eq!(
5861            events.count_of("demand_observed"),
5862            0,
5863            "and it contributed no demand"
5864        );
5865    }
5866
5867    /// D19 says a monitor-only policy is *"skipped entirely by
5868    /// reconciliation"*, and "entirely" is the load-bearing word once two
5869    /// policies share a target.
5870    ///
5871    /// This defect was found by review rather than by the test above, which
5872    /// cannot see it: there, the monitor-only policy is the *only* policy, so
5873    /// nobody polls its target and the lookup finds nothing. Give it a
5874    /// repository an autoscale policy already polls and the lookup succeeds —
5875    /// and the monitor-only policy was then allocated for and had a demand
5876    /// observation emitted on its behalf. It still started nothing, because
5877    /// `may_start_runners` is false for it and `HostAllocator` refuses it by
5878    /// name, so no ceiling was ever at risk. It simply was not skipped.
5879    ///
5880    /// Removing the `owns_runners` guard from the allocation loop was watched
5881    /// failing this test before it was restored:
5882    /// `a monitor-only policy was allocated for: [… limiting_factor:
5883    /// MonitorOnly]`.
5884    #[tokio::test]
5885    async fn a_monitor_only_policy_sharing_a_target_is_still_skipped_entirely() {
5886        let lock = Arc::new(CountingLock::new());
5887        let launcher = Arc::new(FakeLauncher::new());
5888        let events = Arc::new(EventLog::new());
5889        let mut reconciler = Reconciler::new(
5890            host_with(4),
5891            ReconcilerPorts {
5892                demand: Arc::new(FakeDemand::ready(2, &repo("acme/app"))),
5893                launcher: Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
5894                lock: Arc::clone(&lock) as Arc<dyn AllocationLock>,
5895                directory: Arc::new(FakeDirectory::default()),
5896                clock: Arc::new(FakeClock::default()),
5897                jitter: Arc::new(NoJitter),
5898                events: Arc::clone(&events) as Arc<dyn EventSink>,
5899            },
5900        );
5901
5902        let watcher = fixtures::policy()
5903            .id(PolicyId::from_u128(2))
5904            .repository("acme/app")
5905            .monitor_only()
5906            .active()
5907            .build();
5908
5909        let report = reconciler
5910            .reconcile(&[policy(1, "acme/app", 4), watcher])
5911            .await;
5912
5913        assert_eq!(report.started, 2, "the autoscale policy is served normally");
5914        assert_eq!(report.monitor_only, vec![PolicyId::from_u128(2)]);
5915        assert_eq!(
5916            events.count_of("demand_observed"),
5917            1,
5918            "the demand observation belongs to the autoscale policy alone"
5919        );
5920        assert!(
5921            report
5922                .allocations
5923                .iter()
5924                .all(|a| a.policy_id == PolicyId::from_u128(1)),
5925            "a monitor-only policy was allocated for: {:?}",
5926            report.allocations
5927        );
5928        assert_eq!(
5929            lock.acquisitions(),
5930            2,
5931            "one hold per runtime created, and none on behalf of the monitor-only policy. \
5932             It was three before the budget was checked at the top of the loop rather than \
5933             after the re-read, which cost every policy a surplus hold to discover there \
5934             was nothing left to grant"
5935        );
5936    }
5937
5938    #[tokio::test]
5939    async fn the_monitor_only_refusal_is_asserted_on_the_mode_not_on_a_missing_ceiling() {
5940        // The specification requires this to be asserted rather than deduced
5941        // from `max_capacity` being absent. `HostAllocator` reports it by name,
5942        // and this loop reaches that arm through `owns_runners`, which is a
5943        // question about the mode.
5944        let monitor = fixtures::monitor_only_policy();
5945        assert!(!monitor.owns_runners());
5946        assert_eq!(monitor.max_capacity(), None);
5947
5948        let host = host_with(10);
5949        let attempts: Vec<RunnerAttempt> = Vec::new();
5950        let mut allocator = HostAllocator::from_attempts(&host, &attempts);
5951        let allocation = allocator.allocate(&monitor, 10_000);
5952        assert_eq!(allocation.limiting_factor, LimitingFactor::MonitorOnly);
5953        assert_eq!(allocation.to_start, 0);
5954        assert_eq!(
5955            allocator.headroom(),
5956            10,
5957            "and it consumes no headroom, so an autoscale policy on the same host is \
5958             unaffected"
5959        );
5960    }
5961
5962    // =======================================================================
5963    // The surplus runner, and busy protection
5964    // =======================================================================
5965
5966    /// `e1`'s Definition of Done: *"A surplus attempt that receives no job
5967    /// reaches a terminal state recorded as an idle exit, is cleaned, and is not
5968    /// reported as a failure."*
5969    #[tokio::test]
5970    async fn a_surplus_attempt_is_cleaned_as_an_idle_exit_and_not_as_a_failure() {
5971        let launcher = Arc::new(FakeLauncher::new().seeded(vec![
5972            concluded(1, 1, AttemptOutcome::ExitedIdleWithoutWork),
5973            concluded(
5974                2,
5975                1,
5976                AttemptOutcome::failed(FailureReason::JitRequestFailed),
5977            ),
5978            concluded(3, 1, AttemptOutcome::CompletedJob),
5979        ]));
5980        let mut harness = Harness::build(
5981            host_with(4),
5982            Arc::clone(&launcher),
5983            Arc::new(FakeDemand::ready(0, &repo("acme/app"))),
5984            Arc::new(InProcessAllocationLock::new()),
5985        );
5986
5987        let report = harness
5988            .reconciler
5989            .reconcile(&[policy(1, "acme/app", 4)])
5990            .await;
5991
5992        assert_eq!(report.cleaned, 3);
5993        assert_eq!(report.idle_exits, 1, "the surplus case, counted apart");
5994        assert_eq!(
5995            report.failures, 1,
5996            "only the failed attempt is a failure; the idle exit and the completed job are \
5997             not"
5998        );
5999        assert_eq!(launcher.cleaned().len(), 3);
6000        assert!(launcher.snapshot().is_empty());
6001
6002        let cleaned: Vec<OutcomeKind> = harness
6003            .events
6004            .events()
6005            .into_iter()
6006            .filter_map(|event| match event {
6007                LifecycleEvent::AttemptCleaned { outcome, .. } => Some(outcome),
6008                _ => None,
6009            })
6010            .collect();
6011        assert!(cleaned.contains(&OutcomeKind::IdleExit));
6012        assert!(
6013            !OutcomeKind::IdleExit.is_failure(),
6014            "an idle exit rendered as a failure sends an operator hunting a fault that does \
6015             not exist"
6016        );
6017    }
6018
6019    /// `e1`'s Definition of Done: *"A scale-down request with a busy attempt
6020    /// removes nothing and leaves the attempt `busy`."*
6021    #[tokio::test]
6022    async fn scale_down_removes_nothing_from_a_busy_attempt() {
6023        let busy = attempt_in(AttemptState::Busy, 1, 1);
6024        let launcher = Arc::new(FakeLauncher::new().seeded(vec![
6025            busy.clone(),
6026            attempt_in(AttemptState::Starting, 2, 1),
6027            concluded(3, 1, AttemptOutcome::CompletedJob),
6028        ]));
6029        let harness = Harness::build(
6030            host_with(4),
6031            Arc::clone(&launcher),
6032            Arc::new(FakeDemand::ready(0, &repo("acme/app"))),
6033            Arc::new(InProcessAllocationLock::new()),
6034        );
6035
6036        let report = harness
6037            .reconciler
6038            .scale_down(&policy(1, "acme/app", 4))
6039            .await;
6040
6041        assert_eq!(report.refused_busy, 1);
6042        assert_eq!(
6043            report.retained, 1,
6044            "the `starting` attempt is not ended either"
6045        );
6046        assert_eq!(report.removed, 1, "only the concluded attempt is reclaimed");
6047
6048        let after = launcher.snapshot();
6049        let still_busy = after
6050            .iter()
6051            .find(|a| a.id == AttemptId::from_u128(1))
6052            .expect("the busy attempt is still there");
6053        assert_eq!(
6054            still_busy.state(),
6055            AttemptState::Busy,
6056            "scale-down removed nothing from a runner that is executing a job, and left it \
6057             busy"
6058        );
6059        assert!(!launcher.cleaned().contains(&AttemptId::from_u128(1)));
6060
6061        // And the domain refuses it from the other side too, by name, so a
6062        // future caller that tried anyway would not get a generic transition
6063        // error.
6064        let mut busy = busy;
6065        assert!(matches!(
6066            busy.clean(fixtures::created_at()),
6067            Err(runner_manager_domain::attempt::AttemptError::BusyCannotBeCleaned)
6068        ));
6069        assert_eq!(harness.events.count_of("scale_down_refused"), 1);
6070    }
6071
6072    // =======================================================================
6073    // The schedule
6074    // =======================================================================
6075
6076    #[test]
6077    fn the_default_interval_is_sixty_seconds_and_the_floor_is_thirty() {
6078        assert_eq!(RefreshInterval::DEFAULT_SECS, 60);
6079        assert_eq!(RefreshInterval::MIN_SECS, 30);
6080        assert_eq!(PollSchedule::floor(), Duration::from_secs(30));
6081        assert!(
6082            RefreshInterval::from_secs(29).is_err(),
6083            "the floor is a rate-budget constraint, and a caller must not be able to write \
6084             a shorter interval at all"
6085        );
6086
6087        let mut schedule = PollSchedule::new(RefreshInterval::default());
6088        let next = schedule.next_poll(None, fixtures::created_at(), &NoJitter);
6089        assert_eq!(next.delay, Duration::from_secs(60));
6090        assert_eq!(next.pace, PollPace::Nominal);
6091
6092        let mut floored = PollSchedule::new(RefreshInterval::from_secs(30).unwrap());
6093        assert_eq!(
6094            floored
6095                .next_poll(None, fixtures::created_at(), &NoJitter)
6096                .delay,
6097            Duration::from_secs(30)
6098        );
6099    }
6100
6101    /// `e1`'s Definition of Done: *"The poll interval … increases under a
6102    /// rate-limit signal, and the increase is visible in emitted state rather
6103    /// than silent."*
6104    #[test]
6105    fn a_rate_limit_increases_the_delay_and_names_itself() {
6106        let now = fixtures::created_at();
6107        let mut schedule = PollSchedule::new(RefreshInterval::default());
6108
6109        let limited = RefreshState::RateLimited(RateLimited {
6110            kind: RateLimitKind::Secondary,
6111            retry_after: Some(Duration::from_secs(300)),
6112            remaining: None,
6113            reset_unix_secs: None,
6114        });
6115        let next = schedule.next_poll(Some(&limited), now, &NoJitter);
6116
6117        assert_eq!(next.delay, Duration::from_secs(300));
6118        assert_eq!(
6119            next.pace,
6120            PollPace::RateLimited {
6121                kind: RateLimitKind::Secondary
6122            },
6123            "the increase is reported, never hidden"
6124        );
6125        assert!(next.pace.is_throttled());
6126        assert_eq!(next.pace.as_str(), "rate_limited_secondary");
6127    }
6128
6129    /// Constraint on this task: *"Read `RefreshState::retry_delay` as an
6130    /// absolute floor, not an addend."*
6131    #[test]
6132    fn the_retry_delay_is_an_absolute_floor_and_never_an_addend() {
6133        let now = fixtures::created_at();
6134        let mut schedule = PollSchedule::new(RefreshInterval::default());
6135
6136        let limited = RefreshState::RateLimited(RateLimited {
6137            kind: RateLimitKind::Primary,
6138            retry_after: Some(Duration::from_secs(300)),
6139            remaining: Some(0),
6140            reset_unix_secs: None,
6141        });
6142
6143        // Five successive answers, each carrying the window that is *left*.
6144        // An addend would compound: 360, 660, 960 … and look like a hang.
6145        for _ in 0..5 {
6146            let next = schedule.next_poll(Some(&limited), now, &NoJitter);
6147            assert_eq!(
6148                next.delay,
6149                Duration::from_secs(300),
6150                "the delay is `max(interval, retry_delay)`; `interval + retry_delay` would \
6151                 have compounded on every successive retry"
6152            );
6153        }
6154
6155        // And when GitHub asks for less than the interval, the interval wins:
6156        // the floor is never crossed to catch up.
6157        let brief = RefreshState::RateLimited(RateLimited {
6158            kind: RateLimitKind::Secondary,
6159            retry_after: Some(Duration::from_secs(5)),
6160            remaining: None,
6161            reset_unix_secs: None,
6162        });
6163        let next = schedule.next_poll(Some(&brief), now, &NoJitter);
6164        assert_eq!(
6165            next.delay,
6166            Duration::from_secs(60),
6167            "a short `retry-after` may not drop the loop below its own interval"
6168        );
6169        assert!(next.delay >= PollSchedule::floor());
6170    }
6171
6172    #[test]
6173    fn no_branch_of_the_schedule_can_go_below_the_thirty_second_floor() {
6174        let now = fixtures::created_at();
6175        let states = [
6176            None,
6177            Some(RefreshState::Offline),
6178            Some(RefreshState::RateLimited(RateLimited {
6179                kind: RateLimitKind::Secondary,
6180                retry_after: Some(Duration::from_secs(1)),
6181                remaining: None,
6182                reset_unix_secs: None,
6183            })),
6184            Some(RefreshState::LockedOut {
6185                retry_after: Duration::from_secs(1),
6186            }),
6187            Some(RefreshState::Unauthorized),
6188            Some(RefreshState::Forbidden { message: None }),
6189            Some(RefreshState::Failed {
6190                status: Some(500),
6191                message: "server error".into(),
6192            }),
6193            Some(RefreshState::Cancelled),
6194        ];
6195
6196        for state in &states {
6197            let mut schedule = PollSchedule::new(RefreshInterval::from_secs(30).unwrap());
6198            let next = schedule.next_poll(state.as_ref(), now, &NoJitter);
6199            assert!(
6200                next.delay >= PollSchedule::floor(),
6201                "{state:?} scheduled a poll {}ms away, under the 30-second floor",
6202                next.delay.as_millis()
6203            );
6204        }
6205    }
6206
6207    #[test]
6208    fn an_offline_run_backs_off_with_jitter_and_a_recovery_resets_it() {
6209        let now = fixtures::created_at();
6210        let mut schedule = PollSchedule::new(RefreshInterval::default());
6211
6212        // Doubling, from the nominal interval.
6213        let mut previous = Duration::ZERO;
6214        for consecutive in 1..=6_u32 {
6215            let next = schedule.next_poll(Some(&RefreshState::Offline), now, &NoJitter);
6216            assert_eq!(next.pace, PollPace::Offline { consecutive });
6217            assert!(
6218                next.delay >= previous,
6219                "the back-off must not shrink while the outage continues"
6220            );
6221            assert!(next.delay >= Duration::from_secs(60));
6222            previous = next.delay;
6223        }
6224        assert!(previous <= MAX_OFFLINE_BACKOFF, "and it is capped");
6225
6226        // Jitter widens the delay rather than narrowing it, so a fleet of
6227        // agents does not retry in lockstep.
6228        let mut jittered = PollSchedule::new(RefreshInterval::default());
6229        let none = jittered.next_poll(Some(&RefreshState::Offline), now, &NoJitter);
6230        let mut jittered = PollSchedule::new(RefreshInterval::default());
6231        let full = jittered.next_poll(Some(&RefreshState::Offline), now, &FixedJitter(0.999));
6232        assert!(full.delay > none.delay);
6233        assert!(full.delay <= none.delay.mul_f64(1.0 + JITTER_RATIO));
6234
6235        // Recovery resets the run with no bookkeeping of its own.
6236        assert_eq!(schedule.consecutive_offline(), 6);
6237        let recovered = schedule.next_poll(None, now, &NoJitter);
6238        assert_eq!(recovered.pace, PollPace::Nominal);
6239        assert_eq!(recovered.delay, Duration::from_secs(60));
6240        assert_eq!(schedule.consecutive_offline(), 0);
6241    }
6242
6243    #[test]
6244    fn the_offline_state_states_the_twenty_four_hour_bound() {
6245        assert_eq!(
6246            GITHUB_CANCELS_QUEUED_JOBS_AFTER,
6247            Duration::from_secs(24 * 60 * 60)
6248        );
6249
6250        let brief = OfflineState::new(1, Duration::from_secs(120));
6251        let rendered = brief.to_string();
6252        assert!(rendered.contains("24 hours"), "{rendered}");
6253        assert!(rendered.contains("Retrying in 120s"), "{rendered}");
6254        assert!(!brief.has_outlasted_the_queue());
6255
6256        let long = brief.since(GITHUB_CANCELS_QUEUED_JOBS_AFTER + Duration::from_secs(1));
6257        assert!(long.has_outlasted_the_queue());
6258        assert!(
6259            long.to_string().contains("queued work has been lost"),
6260            "{long}"
6261        );
6262
6263        // "We cannot tell" is not "not yet".
6264        assert!(!OfflineState::new(9, Duration::from_secs(60)).has_outlasted_the_queue());
6265    }
6266
6267    // =======================================================================
6268    // Idle polling and the shared quota
6269    // =======================================================================
6270
6271    fn quota(remaining: u64, reset_in_secs: i64, now: Timestamp) -> RateLimitSnapshot {
6272        RateLimitSnapshot {
6273            limit: 5_000,
6274            remaining,
6275            used: Some(5_000 - remaining),
6276            reset_unix_secs: u64::try_from(now.timestamp() + reset_in_secs).expect("positive"),
6277            observed_at: now,
6278        }
6279    }
6280
6281    fn quiet() -> PollConditions {
6282        PollConditions {
6283            quiet: true,
6284            quota: None,
6285            requests_per_pass: 0,
6286        }
6287    }
6288
6289    #[test]
6290    fn a_quiet_pass_polls_at_the_idle_interval_and_a_busy_one_at_the_active_interval() {
6291        let now = fixtures::created_at();
6292        let mut schedule =
6293            PollSchedule::new(RefreshInterval::default()).with_idle(Duration::from_secs(10));
6294
6295        let idle = schedule.next_poll_with(None, quiet(), now, &NoJitter);
6296        assert_eq!(idle.delay, Duration::from_secs(10));
6297        assert_eq!(idle.pace, PollPace::Idle);
6298        assert!(!idle.pace.is_throttled(), "idle is faster, not a slowdown");
6299
6300        let busy = schedule.next_poll_with(None, PollConditions::default(), now, &NoJitter);
6301        assert_eq!(busy.delay, Duration::from_secs(60));
6302        assert_eq!(busy.pace, PollPace::Nominal);
6303    }
6304
6305    #[test]
6306    fn a_schedule_never_told_an_idle_interval_keeps_its_active_one() {
6307        let now = fixtures::created_at();
6308        let mut schedule = PollSchedule::new(RefreshInterval::default());
6309        assert_eq!(
6310            schedule.next_poll_with(None, quiet(), now, &NoJitter).delay,
6311            Duration::from_secs(60)
6312        );
6313    }
6314
6315    #[test]
6316    fn an_idle_interval_is_clamped_between_its_floor_and_the_active_interval() {
6317        let schedule = PollSchedule::new(RefreshInterval::from_secs(30).expect("the floor"))
6318            .with_idle(Duration::from_secs(120));
6319        assert_eq!(
6320            schedule.idle(),
6321            Duration::from_secs(30),
6322            "an idle interval above the active one would make quiet targets slower to react"
6323        );
6324        let schedule =
6325            PollSchedule::new(RefreshInterval::default()).with_idle(Duration::from_secs(1));
6326        assert_eq!(schedule.idle(), PollSchedule::idle_floor());
6327    }
6328
6329    /// The owner's fourth requirement: when the shared quota runs low, stretch
6330    /// the interval automatically, and say so.
6331    #[test]
6332    fn a_low_quota_stretches_polling_and_wins_over_idle() {
6333        let now = fixtures::created_at();
6334        let mut schedule =
6335            PollSchedule::new(RefreshInterval::default()).with_idle(Duration::from_secs(10));
6336
6337        let healthy = schedule.next_poll_with(
6338            None,
6339            PollConditions {
6340                quiet: true,
6341                quota: Some(quota(1_000, 3_000, now)),
6342                requests_per_pass: 0,
6343            },
6344            now,
6345            &NoJitter,
6346        );
6347        assert_eq!(healthy.pace, PollPace::Idle, "a fifth left is still normal");
6348
6349        let low = schedule.next_poll_with(
6350            None,
6351            PollConditions {
6352                quiet: true,
6353                quota: Some(quota(999, 3_000, now)),
6354                requests_per_pass: 0,
6355            },
6356            now,
6357            &NoJitter,
6358        );
6359        assert_eq!(low.delay, Duration::from_secs(120));
6360        assert_eq!(
6361            low.pace,
6362            PollPace::Stretched {
6363                pressure: PrimaryPressure::Low
6364            }
6365        );
6366        assert!(low.pace.is_throttled());
6367        assert_eq!(low.pace.as_str(), "stretched_low_budget");
6368
6369        let critical = schedule.next_poll_with(
6370            None,
6371            PollConditions {
6372                quiet: false,
6373                quota: Some(quota(100, 3_000, now)),
6374                requests_per_pass: 0,
6375            },
6376            now,
6377            &NoJitter,
6378        );
6379        assert_eq!(critical.delay, Duration::from_secs(240));
6380        assert_eq!(critical.pace.as_str(), "stretched_critical_budget");
6381    }
6382
6383    #[test]
6384    fn a_stretch_never_outlasts_the_quotas_reset_nor_undercuts_the_active_interval() {
6385        let now = fixtures::created_at();
6386        let mut schedule = PollSchedule::new(RefreshInterval::default());
6387
6388        let soon = schedule.next_poll_with(
6389            None,
6390            PollConditions {
6391                quiet: false,
6392                quota: Some(quota(10, 90, now)),
6393                requests_per_pass: 0,
6394            },
6395            now,
6396            &NoJitter,
6397        );
6398        assert_eq!(
6399            soon.delay,
6400            Duration::from_secs(90),
6401            "the bucket refills in 90 s; waiting 240 s buys nothing"
6402        );
6403
6404        let imminent = schedule.next_poll_with(
6405            None,
6406            PollConditions {
6407                quiet: false,
6408                quota: Some(quota(10, 5, now)),
6409                requests_per_pass: 0,
6410            },
6411            now,
6412            &NoJitter,
6413        );
6414        assert_eq!(imminent.delay, Duration::from_secs(60));
6415
6416        let expired = schedule.next_poll_with(
6417            None,
6418            PollConditions {
6419                quiet: true,
6420                quota: Some(quota(10, -5, now)),
6421                requests_per_pass: 0,
6422            },
6423            now,
6424            &NoJitter,
6425        );
6426        assert_eq!(
6427            expired.pace,
6428            PollPace::Nominal,
6429            "a reading from a window that has reset says nothing; the schedule here had no \
6430             idle interval, so it is the active one"
6431        );
6432    }
6433
6434    #[test]
6435    fn an_idle_target_too_large_to_poll_that_often_stretches_its_idle_interval() {
6436        let now = fixtures::created_at();
6437        let mut schedule =
6438            PollSchedule::new(RefreshInterval::default()).with_idle(Duration::from_secs(10));
6439        let small = schedule.next_poll_with(
6440            None,
6441            PollConditions {
6442                quiet: true,
6443                quota: None,
6444                requests_per_pass: 40,
6445            },
6446            now,
6447            &NoJitter,
6448        );
6449        assert_eq!(small.delay, Duration::from_secs(10), "ten repositories fit");
6450        let organization = schedule.next_poll_with(
6451            None,
6452            PollConditions {
6453                quiet: true,
6454                quota: None,
6455                requests_per_pass: 400,
6456            },
6457            now,
6458            &NoJitter,
6459        );
6460        assert_eq!(
6461            organization.delay,
6462            Duration::from_secs(80),
6463            "400 requests a pass at 300 a minute is one pass every 80 s"
6464        );
6465        assert_eq!(organization.pace, PollPace::Idle);
6466    }
6467
6468    #[test]
6469    fn a_rate_limit_wait_is_jittered_upward_only() {
6470        let now = fixtures::created_at();
6471        let mut schedule = PollSchedule::new(RefreshInterval::default());
6472        let limited = RefreshState::RateLimited(RateLimited {
6473            kind: RateLimitKind::Secondary,
6474            retry_after: Some(Duration::from_secs(300)),
6475            remaining: None,
6476            reset_unix_secs: None,
6477        });
6478        assert_eq!(
6479            schedule
6480                .next_poll(Some(&limited), now, &FixedJitter(0.5))
6481                .delay,
6482            Duration::from_secs(315),
6483            "every loop shares one quiet period; without jitter they all wake together"
6484        );
6485        assert_eq!(
6486            schedule.next_poll(Some(&limited), now, &NoJitter).delay,
6487            Duration::from_secs(300),
6488            "never shorter than GitHub asked"
6489        );
6490    }
6491
6492    #[tokio::test]
6493    async fn demand_this_host_did_not_serve_keeps_the_target_off_the_idle_pace() {
6494        // Capacity zero would refuse the policy outright; a host already full
6495        // is the ordinary way matched demand goes unserved for a pass.
6496        let launcher =
6497            Arc::new(FakeLauncher::new().seeded(vec![attempt_in(AttemptState::Busy, 9, 9)]));
6498        let demand = Arc::new(FakeDemand::ready(2, &repo("acme/app")));
6499        let mut harness = Harness::build(
6500            host_with(1),
6501            launcher,
6502            demand,
6503            Arc::new(InProcessAllocationLock::new()),
6504        );
6505        harness
6506            .reconciler
6507            .set_intervals(RefreshInterval::default(), Duration::from_secs(10));
6508        let policy = policy(1, "acme/app", 4);
6509
6510        let report = harness
6511            .reconciler
6512            .reconcile(std::slice::from_ref(&policy))
6513            .await;
6514
6515        assert_eq!(report.started, 0, "the host is full");
6516        assert_eq!(
6517            report.next_poll.pace,
6518            PollPace::Nominal,
6519            "matched demand it could not serve is work waiting, not idle"
6520        );
6521    }
6522
6523    #[tokio::test]
6524    async fn a_target_with_nothing_to_do_is_polled_at_the_idle_interval() {
6525        let mut harness = Harness::simple(4, 0, "acme/app");
6526        harness
6527            .reconciler
6528            .set_intervals(RefreshInterval::default(), Duration::from_secs(10));
6529        let policy = policy(1, "acme/app", 4);
6530
6531        let report = harness
6532            .reconciler
6533            .reconcile(std::slice::from_ref(&policy))
6534            .await;
6535
6536        assert_eq!(report.next_poll.pace, PollPace::Idle);
6537        assert_eq!(report.next_poll.delay, Duration::from_secs(10));
6538    }
6539
6540    #[tokio::test]
6541    async fn a_target_whose_runs_are_changing_is_polled_at_the_active_interval() {
6542        let mut harness = Harness::simple(4, 0, "acme/app");
6543        harness
6544            .reconciler
6545            .set_intervals(RefreshInterval::default(), Duration::from_secs(10));
6546        harness.demand.set(PollOutcome::Ready(
6547            QueuedDemand::of(repo("acme/app"), Vec::new()).with_busy(repo("acme/app")),
6548        ));
6549        let policy = policy(1, "acme/app", 4);
6550
6551        let report = harness
6552            .reconciler
6553            .reconcile(std::slice::from_ref(&policy))
6554            .await;
6555
6556        assert_eq!(report.next_poll.pace, PollPace::Nominal);
6557        assert_eq!(report.next_poll.delay, Duration::from_secs(60));
6558    }
6559
6560    #[tokio::test]
6561    async fn a_target_with_a_live_runner_is_polled_at_the_active_interval() {
6562        let launcher =
6563            Arc::new(FakeLauncher::new().seeded(vec![attempt_in(AttemptState::Busy, 1, 1)]));
6564        let demand = Arc::new(FakeDemand::ready(0, &repo("acme/app")));
6565        let mut harness = Harness::build(
6566            host_with(4),
6567            launcher,
6568            demand,
6569            Arc::new(InProcessAllocationLock::new()),
6570        );
6571        harness
6572            .reconciler
6573            .set_intervals(RefreshInterval::default(), Duration::from_secs(10));
6574        let policy = policy(1, "acme/app", 4);
6575
6576        let report = harness
6577            .reconciler
6578            .reconcile(std::slice::from_ref(&policy))
6579            .await;
6580
6581        assert_eq!(
6582            report.next_poll.pace,
6583            PollPace::Nominal,
6584            "a job is running: its follow-on work is what the active interval watches for"
6585        );
6586    }
6587
6588    #[tokio::test]
6589    async fn a_pass_that_starts_a_runner_is_not_idle() {
6590        let mut harness = Harness::simple(4, 2, "acme/app");
6591        harness
6592            .reconciler
6593            .set_intervals(RefreshInterval::default(), Duration::from_secs(10));
6594        let policy = policy(1, "acme/app", 4);
6595
6596        let report = harness
6597            .reconciler
6598            .reconcile(std::slice::from_ref(&policy))
6599            .await;
6600
6601        assert!(report.started > 0);
6602        assert_eq!(report.next_poll.pace, PollPace::Nominal);
6603    }
6604
6605    #[tokio::test]
6606    async fn the_quota_reported_with_a_reading_stretches_the_next_poll() {
6607        let mut harness = Harness::simple(4, 0, "acme/app");
6608        harness
6609            .reconciler
6610            .set_intervals(RefreshInterval::default(), Duration::from_secs(10));
6611        let now = harness.clock.now();
6612        harness.demand.set(PollOutcome::Ready(
6613            QueuedDemand::of(repo("acme/app"), Vec::new())
6614                .with_rate_limit(Some(quota(200, 1_800, now))),
6615        ));
6616        let policy = policy(1, "acme/app", 4);
6617
6618        let report = harness
6619            .reconciler
6620            .reconcile(std::slice::from_ref(&policy))
6621            .await;
6622
6623        assert_eq!(
6624            report.next_poll.pace,
6625            PollPace::Stretched {
6626                pressure: PrimaryPressure::Critical
6627            }
6628        );
6629        assert_eq!(report.next_poll.delay, Duration::from_secs(240));
6630    }
6631
6632    // =======================================================================
6633    // Offline, end to end
6634    // =======================================================================
6635
6636    /// `e1`'s Definition of Done: *"An unreachable GitHub yields `offline`, zero
6637    /// new runners, retained existing processes, and jittered backoff; recovery
6638    /// resumes polling and does not double-count a job that was already being
6639    /// served."*
6640    #[tokio::test]
6641    async fn an_unreachable_github_starts_nothing_retains_everything_and_backs_off() {
6642        let live = vec![
6643            attempt_in(AttemptState::Busy, 1, 1),
6644            attempt_in(AttemptState::Starting, 2, 1),
6645        ];
6646        let launcher = Arc::new(FakeLauncher::new().seeded(live.clone()));
6647        let demand = Arc::new(FakeDemand::failing(RefreshState::Offline));
6648        let mut harness = Harness::build(
6649            host_with(8),
6650            Arc::clone(&launcher),
6651            Arc::clone(&demand),
6652            Arc::new(InProcessAllocationLock::new()),
6653        );
6654        let policy = policy(1, "acme/app", 8);
6655
6656        let report = harness
6657            .reconciler
6658            .reconcile(std::slice::from_ref(&policy))
6659            .await;
6660
6661        assert!(report.is_offline());
6662        assert_eq!(report.started, 0, "no new runner during an outage");
6663        assert_eq!(launcher.launches(), 0);
6664        assert_eq!(
6665            launcher.snapshot(),
6666            live,
6667            "existing runner processes are retained, untouched"
6668        );
6669        assert_eq!(report.unreadable, vec![PolicyId::from_u128(1)]);
6670        assert_eq!(report.next_poll.pace, PollPace::Offline { consecutive: 1 });
6671        assert!(report.next_poll.delay >= Duration::from_secs(60));
6672        let offline = report.offline_state().expect("an offline state to display");
6673        assert!(offline.to_string().contains("24 hours"));
6674
6675        // Recovery: two jobs are queued, but only the starting runner can
6676        // cover them. The busy runner consumes capacity and serves other work.
6677        demand.set(PollOutcome::Ready(QueuedDemand::of(
6678            repo("acme/app"),
6679            jobs(2),
6680        )));
6681        let recovered = harness.reconciler.reconcile(&[policy]).await;
6682
6683        assert!(!recovered.is_offline());
6684        assert_eq!(recovered.next_poll.pace, PollPace::Nominal);
6685        assert_eq!(
6686            recovered.started, 1,
6687            "two queued jobs and one starting runner leave one job uncovered"
6688        );
6689        assert_eq!(recovered.allocations[0].active_owned, 2);
6690        assert_eq!(launcher.live_count(), 3);
6691    }
6692
6693    /// One unreachable target must not idle a whole host.
6694    ///
6695    /// The failure that decides the *schedule* is the most severe across every
6696    /// target polled — backing the whole loop off during an outage is the safe
6697    /// error, and `f3` runs one reconciler per target anyway, so in production
6698    /// the two are usually the same thing. What must not follow from that is
6699    /// refusing to serve a policy whose own target answered perfectly well, and
6700    /// the two are easy to conflate because the offline reading is sitting in
6701    /// the same map.
6702    #[tokio::test]
6703    async fn one_offline_target_does_not_stop_a_reachable_one() {
6704        let mut harness = Harness::simple(8, 0, "acme/app");
6705        harness.demand.set_for(
6706            &ScaleTarget::repository("acme/app").unwrap(),
6707            PollOutcome::Ready(QueuedDemand::of(repo("acme/app"), jobs(2))),
6708        );
6709        harness.demand.set_for(
6710            &ScaleTarget::repository("acme/broken").unwrap(),
6711            PollOutcome::Failed(RefreshState::Offline),
6712        );
6713
6714        let report = harness
6715            .reconciler
6716            .reconcile(&[policy(1, "acme/app", 4), policy(2, "acme/broken", 4)])
6717            .await;
6718
6719        assert_eq!(
6720            report.started, 2,
6721            "the reachable target was served; an unreachable sibling repository must not \
6722             idle the host"
6723        );
6724        assert_eq!(report.unreadable, vec![PolicyId::from_u128(2)]);
6725        assert_eq!(harness.demand.polls().len(), 2, "both targets were polled");
6726
6727        // And the schedule takes the worse of the two.
6728        assert!(report.is_offline());
6729        assert_eq!(report.next_poll.pace, PollPace::Offline { consecutive: 1 });
6730    }
6731
6732    /// The 24-hour bound has to be reachable in production, not only in a unit
6733    /// test of [`OfflineState`].
6734    ///
6735    /// This was a real gap: the reconciler built its offline state from the
6736    /// back-off count alone, so `offline_for` was always `None` and
6737    /// [`OfflineState::has_outlasted_the_queue`] could never be true outside a
6738    /// test that constructed the value by hand. An operator whose agent had been
6739    /// offline for two days would have been told that an outage longer than 24
6740    /// hours *would* lose queued work, in the future tense, having already lost
6741    /// it.
6742    ///
6743    /// The elapsed time is measured from the first poll of the run rather than
6744    /// derived from the interval, because the back-off doubles and the two
6745    /// diverge immediately.
6746    #[tokio::test]
6747    async fn a_day_long_outage_says_that_queued_work_has_already_been_lost() {
6748        let clock = Arc::new(FakeClock::default());
6749        let launcher = Arc::new(FakeLauncher::new());
6750        let demand = Arc::new(FakeDemand::failing(RefreshState::Offline));
6751        let mut reconciler = Reconciler::new(
6752            host_with(4),
6753            ReconcilerPorts {
6754                demand: Arc::clone(&demand) as Arc<dyn DemandSource>,
6755                launcher: Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
6756                lock: Arc::new(InProcessAllocationLock::new()),
6757                directory: Arc::new(FakeDirectory::default()),
6758                clock: Arc::clone(&clock) as Arc<dyn Clock>,
6759                jitter: Arc::new(NoJitter),
6760                events: Arc::new(NoEvents),
6761            },
6762        );
6763        let policy = policy(1, "acme/app", 4);
6764
6765        // The outage begins.
6766        let first = reconciler.reconcile(std::slice::from_ref(&policy)).await;
6767        let state = first.offline_state().expect("an offline state");
6768        assert!(!state.has_outlasted_the_queue());
6769        assert!(
6770            state
6771                .to_string()
6772                .contains("an outage longer than that loses"),
6773            "{state}"
6774        );
6775
6776        // A day and a minute later, still unreachable.
6777        clock.advance_secs(24 * 60 * 60 + 60);
6778        let later = reconciler.reconcile(std::slice::from_ref(&policy)).await;
6779        let state = later.offline_state().expect("an offline state");
6780        assert!(state.has_outlasted_the_queue());
6781        assert!(
6782            state.to_string().contains("queued work has been lost"),
6783            "{state}"
6784        );
6785        assert_eq!(launcher.launches(), 0, "and still nothing was started");
6786
6787        // Recovery closes the run, so a *later* outage measures from itself
6788        // rather than from the first one.
6789        demand.set(PollOutcome::Ready(QueuedDemand::of(
6790            repo("acme/app"),
6791            jobs(0),
6792        )));
6793        let recovered = reconciler.reconcile(std::slice::from_ref(&policy)).await;
6794        assert!(recovered.offline_state().is_none());
6795        assert_eq!(reconciler.schedule().offline_for(clock.now()), None);
6796
6797        demand.set(PollOutcome::Failed(RefreshState::Offline));
6798        let again = reconciler.reconcile(std::slice::from_ref(&policy)).await;
6799        assert!(
6800            !again
6801                .offline_state()
6802                .expect("an offline state")
6803                .has_outlasted_the_queue(),
6804            "a new outage must not inherit the age of the one before it"
6805        );
6806    }
6807
6808    /// Finding 5: the adapter, not the lock underneath it.
6809    ///
6810    /// `d1` covers `LockKind::Allocation` including a contended `acquire_at`
6811    /// with a wait. What that does not reach is this adapter: the
6812    /// `spawn_blocking` wrapper, the collapse of both a refused lock and a
6813    /// panicked blocking task into `AllocationLockBusy`, and — the one that
6814    /// would be silent — whether [`AllocationGuard`] really holds the
6815    /// `HostLock`, since dropping it is the only release there is. A guard that
6816    /// dropped the lock on the way out would make every acquisition succeed and
6817    /// the ceiling would hold by luck.
6818    ///
6819    /// The original disclosure said this needed a real filesystem and was
6820    /// therefore expensive. `AppPaths::rooted_at` plus `tempfile` — already a
6821    /// non-dev dependency of this crate — makes it about fifteen lines, so the
6822    /// reason was weaker than stated.
6823    #[tokio::test]
6824    async fn the_file_allocation_lock_excludes_a_second_holder_and_releases_on_drop() {
6825        let root = tempfile::tempdir().expect("a temporary directory");
6826        let paths = Arc::new(runner_manager_platform::paths::AppPaths::rooted_at(
6827            root.path(),
6828        ));
6829        let lock = FileAllocationLock::new(paths).with_wait(Duration::from_millis(50));
6830
6831        let held = lock.acquire().await.expect("an uncontended lock is free");
6832        assert!(
6833            matches!(lock.acquire().await, Err(AllocationLockBusy)),
6834            "a second holder was admitted; on Unix the lock is per open file description \
6835             and on Windows the share mode denies write, so this must be refused even \
6836             from inside the same process"
6837        );
6838
6839        drop(held);
6840        let regained = lock.acquire().await;
6841        assert!(
6842            regained.is_ok(),
6843            "dropping the guard is the only release there is, so a guard that does not \
6844             hold the `HostLock` leaves it held forever"
6845        );
6846    }
6847
6848    #[test]
6849    fn tee_events_reaches_both_sinks() {
6850        // `f3` wires the log sink and `g2`'s buffer at once, and an event that
6851        // reached only one of them would be an activity view missing lines the
6852        // log file has, or the reverse.
6853        let left = Arc::new(EventLog::new());
6854        let right = Arc::new(EventLog::new());
6855        let tee = TeeEvents(
6856            Arc::clone(&left) as Arc<dyn EventSink>,
6857            Arc::clone(&right) as Arc<dyn EventSink>,
6858        );
6859
6860        tee.emit(LifecycleEvent::MonitorOnlySkipped {
6861            policy: PolicyId::from_u128(1),
6862        });
6863
6864        assert_eq!(left.count_of("monitor_only_skipped"), 1);
6865        assert_eq!(right.count_of("monitor_only_skipped"), 1);
6866    }
6867
6868    // =======================================================================
6869    // Budget: the repository list, and the per-target poll
6870    // =======================================================================
6871
6872    #[tokio::test]
6873    async fn the_repository_list_refreshes_far_more_slowly_than_the_demand_poll() {
6874        let clock = Arc::new(FakeClock::default());
6875        let directory = Arc::new(FakeDirectory::of(vec![repo("acme/one"), repo("acme/two")]));
6876        let cache = RepositoryCache::new(
6877            Arc::clone(&directory) as Arc<dyn RepositoryDirectory>,
6878            Arc::clone(&clock) as Arc<dyn Clock>,
6879            RefreshInterval::default(),
6880        );
6881        let target = ScaleTarget::organization("acme").unwrap();
6882
6883        assert_eq!(
6884            cache.ttl(),
6885            Duration::from_secs(60 * u64::from(REPOSITORY_LIST_REFRESH_MULTIPLE))
6886        );
6887
6888        // Every poll inside the window reuses the list.
6889        for _ in 0..REPOSITORY_LIST_REFRESH_MULTIPLE {
6890            let scope = cache.scope_for(&target).await.unwrap();
6891            assert_eq!(scope.repositories().len(), 2);
6892            clock.advance_secs(60);
6893        }
6894        assert_eq!(
6895            directory.calls(),
6896            1,
6897            "re-listing an organization at demand-poll frequency is what exhausts the \
6898             shared request budget"
6899        );
6900        assert_eq!(cache.lookups(), 1);
6901
6902        // Past it, exactly one more.
6903        cache.scope_for(&target).await.unwrap();
6904        assert_eq!(directory.calls(), 2);
6905    }
6906
6907    #[tokio::test]
6908    async fn a_repository_target_never_consults_the_directory() {
6909        let directory = Arc::new(FakeDirectory::of(vec![repo("acme/other")]));
6910        let cache = RepositoryCache::new(
6911            Arc::clone(&directory) as Arc<dyn RepositoryDirectory>,
6912            Arc::new(FakeClock::default()) as Arc<dyn Clock>,
6913            RefreshInterval::default(),
6914        );
6915        let target = ScaleTarget::repository("acme/app").unwrap();
6916
6917        let scope = cache.scope_for(&target).await.unwrap();
6918        assert_eq!(scope.repositories(), &[repo("acme/app")]);
6919        assert_eq!(directory.calls(), 0);
6920    }
6921
6922    #[tokio::test]
6923    async fn two_policies_on_one_target_cost_one_demand_poll_not_two() {
6924        // `04-subsystem-contracts.md` prices a *target*. A loop that spent per
6925        // policy would exceed the projection `f2` admitted the configuration
6926        // against, silently.
6927        let mut harness = Harness::simple(8, 4, "o/r");
6928        harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
6929            repo("o/r"),
6930            [
6931                fixtures::queued_job(&[HOST_LABEL]),
6932                fixtures::queued_job(&[HOST_LABEL]),
6933                fixtures::queued_job(&["rm-home-win-x64-py-isolated"]),
6934                fixtures::queued_job(&["rm-home-win-x64-py-isolated"]),
6935            ],
6936        )));
6937        let mut isolated = fixtures::named_policy("py-isolated", PolicyId::from_u128(2));
6938        isolated.activate().unwrap();
6939        let report = harness
6940            .reconciler
6941            .reconcile(&[policy(1, "o/r", 2), isolated])
6942            .await;
6943
6944        assert_eq!(harness.demand.polls().len(), 1);
6945        assert_eq!(report.targets_read, 1, "profiles share one target reading");
6946        assert_eq!(
6947            report.demand_requests,
6948            runner_manager_github::demand::DEMAND_REQUESTS_PER_REPOSITORY_PER_POLL,
6949            "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"
6950        );
6951        assert_eq!(
6952            report.started, 4,
6953            "native and isolated profiles each get two"
6954        );
6955    }
6956
6957    #[tokio::test]
6958    async fn subset_labels_without_selector_scale_only_the_compatible_local_profile() {
6959        use runner_manager_domain::model::{Arch, HostLabel, Os};
6960        use runner_manager_domain::policy::RoutingLabels;
6961        let mut harness = Harness::build(
6962            fixtures::host()
6963                .os(Os::MacOs)
6964                .architecture(Arch::Arm64)
6965                .capacity(10)
6966                .build(),
6967            Arc::new(FakeLauncher::new()),
6968            Arc::new(FakeDemand::ready(0, &repo("o/r"))),
6969            Arc::new(InProcessAllocationLock::new()),
6970        );
6971        harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
6972            repo("o/r"),
6973            [
6974                fixtures::queued_job(&["self-hosted", "macOS", "ARM64"]),
6975                fixtures::queued_job(&["SELF-HOSTED", "MACOS", "arm64"]),
6976                fixtures::queued_job(&["self-hosted", "macOS", "X64"]),
6977                fixtures::queued_job(&["self-hosted", "Windows", "ARM64"]),
6978                fixtures::queued_job(&["self-hosted", "macOS", "ARM64", "gpu"]),
6979                fixtures::queued_job(&["rm-other-osx-arm64", "macOS", "ARM64"]),
6980            ],
6981        )));
6982        let mut local = fixtures::policy()
6983            .id(PolicyId::from_u128(1))
6984            .repository("o/r")
6985            .autoscale("macmini", 10)
6986            .routing_labels(RoutingLabels::derive(
6987                &HostLabel::new("macmini").unwrap(),
6988                Os::MacOs,
6989                Arch::Arm64,
6990            ))
6991            .active()
6992            .build();
6993        for label in ["self-hosted", "macos", "arm64", "extra"] {
6994            local.add_routing_label(fixtures::label(label)).unwrap();
6995        }
6996        let mut foreign = local.to_persisted();
6997        foreign.id = PolicyId::from_u128(2);
6998        foreign.host_id = HostId::from_u128(0xdead);
6999        let foreign = ScalePolicy::from_persisted(foreign).unwrap();
7000        let report = harness.reconciler.reconcile(&[local, foreign]).await;
7001        assert_eq!(report.started, 2);
7002        assert_eq!(report.targets_read, 1);
7003        assert!(harness.events.events().iter().any(|event| matches!(
7004            event,
7005            LifecycleEvent::DemandObserved {
7006                demand: 2,
7007                not_matched: 4,
7008                ..
7009            }
7010        )));
7011        assert!(
7012            !harness
7013                .events
7014                .events()
7015                .iter()
7016                .any(|event| matches!(event, LifecycleEvent::RoutingRefused { .. }))
7017        );
7018    }
7019
7020    #[tokio::test]
7021    async fn isolated_overlap_never_falls_back_to_native_in_either_state_or_order() {
7022        use runner_manager_domain::execution::{
7023            Backend, ExecutionPolicy, ImageReference, ResourceLimits,
7024        };
7025        let mut native = policy(1, "o/r", 2);
7026        native
7027            .add_routing_label(fixtures::label("self-hosted"))
7028            .unwrap();
7029        let mut isolated = fixtures::named_policy("py-isolated", PolicyId::from_u128(2));
7030        isolated
7031            .add_routing_label(fixtures::label("self-hosted"))
7032            .unwrap();
7033        isolated
7034            .set_execution_policy(ExecutionPolicy::Isolated {
7035                backend: Backend::Oci,
7036                image: ImageReference::new(format!(
7037                    "registry.example/runner@sha256:{}",
7038                    "a".repeat(64)
7039                ))
7040                .unwrap(),
7041                resources: ResourceLimits {
7042                    cpu_millis: 1000,
7043                    memory_mib: 512,
7044                    disk_mib: 1024,
7045                },
7046            })
7047            .unwrap();
7048        assert!(!isolated.may_start_runners());
7049        let mut active_isolated = isolated.clone();
7050        active_isolated.activate().unwrap();
7051        for profiles in [
7052            [native.clone(), isolated.clone()],
7053            [isolated.clone(), native.clone()],
7054            [native.clone(), active_isolated.clone()],
7055            [active_isolated, native.clone()],
7056        ] {
7057            let mut harness = Harness::simple(4, 0, "o/r");
7058            harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
7059                repo("o/r"),
7060                [fixtures::queued_job(&["SELF-HOSTED"])],
7061            )));
7062            let report = harness.reconciler.reconcile(&profiles).await;
7063            assert_eq!(report.started, 0);
7064            assert!(harness.events.events().iter().any(|event| matches!(
7065                event,
7066                LifecycleEvent::RoutingRefused {
7067                    reason: RoutingRefusal::OverlappingProfiles,
7068                    count: 1,
7069                    ..
7070                }
7071            )));
7072        }
7073        // A job explicitly requiring the unavailable isolated selector cannot
7074        // be served by the native sibling either.
7075        let mut harness = Harness::simple(4, 0, "o/r");
7076        harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
7077            repo("o/r"),
7078            [fixtures::queued_job(&["rm-home-win-x64-py-isolated"])],
7079        )));
7080        assert_eq!(
7081            harness
7082                .reconciler
7083                .reconcile(&[native, isolated])
7084                .await
7085                .started,
7086            0
7087        );
7088    }
7089
7090    #[tokio::test]
7091    async fn shared_optional_labels_do_not_duplicate_native_and_isolated_demand() {
7092        let mut harness = Harness::simple(2, 0, "o/r");
7093        harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
7094            repo("o/r"),
7095            [
7096                fixtures::queued_job(&[HOST_LABEL, "gpu"]),
7097                fixtures::queued_job(&["rm-home-win-x64-py-isolated", "gpu"]),
7098                fixtures::queued_job(&["gpu"]),
7099                fixtures::unresolvable_job(),
7100            ],
7101        )));
7102        let mut native = policy(1, "o/r", 2);
7103        native.add_routing_label(fixtures::label("gpu")).unwrap();
7104        let mut isolated = fixtures::named_policy("py-isolated", PolicyId::from_u128(2));
7105        isolated.add_routing_label(fixtures::label("gpu")).unwrap();
7106        isolated.activate().unwrap();
7107
7108        let report = harness.reconciler.reconcile(&[native, isolated]).await;
7109        assert_eq!(harness.demand.polls().len(), 1);
7110        assert_eq!(report.targets_read, 1);
7111        assert_eq!(report.started, 2);
7112        assert_eq!(report.allocations.len(), 2);
7113        assert!(
7114            report
7115                .allocations
7116                .iter()
7117                .all(|allocation| allocation.to_start == 1)
7118        );
7119        assert!(harness.events.events().iter().any(|event| matches!(
7120            event,
7121            LifecycleEvent::RoutingRefused {
7122                reason: RoutingRefusal::OverlappingProfiles,
7123                count: 1,
7124                ..
7125            }
7126        )));
7127        assert_eq!(
7128            harness
7129                .events
7130                .events()
7131                .iter()
7132                .filter(|event| matches!(
7133                    event,
7134                    LifecycleEvent::DemandObserved {
7135                        unresolvable: 1,
7136                        ..
7137                    }
7138                ))
7139                .count(),
7140            2,
7141            "an expression stays unresolvable for both profiles"
7142        );
7143    }
7144
7145    #[tokio::test]
7146    async fn missing_required_selector_labels_do_not_match_and_overlaps_show_remedies() {
7147        let mut harness = Harness::simple(4, 0, "o/r");
7148        harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
7149            repo("o/r"),
7150            [fixtures::queued_job(&[
7151                HOST_LABEL,
7152                "rm-home-win-x64-py-isolated",
7153            ])],
7154        )));
7155        let mut isolated = fixtures::named_policy("py-isolated", PolicyId::from_u128(2));
7156        isolated.activate().unwrap();
7157        let report = harness
7158            .reconciler
7159            .reconcile(&[policy(1, "o/r", 2), isolated])
7160            .await;
7161        assert_eq!(report.started, 0);
7162        assert_eq!(
7163            harness
7164                .events
7165                .events()
7166                .iter()
7167                .filter(|event| matches!(
7168                    event,
7169                    LifecycleEvent::DemandObserved {
7170                        demand: 0,
7171                        not_matched: 1,
7172                        ..
7173                    }
7174                ))
7175                .count(),
7176            2
7177        );
7178
7179        // Domain constructors permit extra selector-shaped labels. Routing
7180        // depends on all required labels and unique ownership, not their shape.
7181        // The store separately rejects sibling selector reuse on write/load.
7182        let mut superset = policy(1, "o/r", 2);
7183        assert!(
7184            superset
7185                .add_routing_label(fixtures::label("rm-home-win-x64-py-isolated"))
7186                .unwrap()
7187        );
7188        let mut sibling = fixtures::named_policy("py-isolated", PolicyId::from_u128(2));
7189        sibling.activate().unwrap();
7190        let mut unique = Harness::simple(4, 0, "o/r");
7191        unique.demand.set(PollOutcome::Ready(QueuedDemand::of(
7192            repo("o/r"),
7193            [fixtures::queued_job(&[
7194                HOST_LABEL,
7195                "rm-home-win-x64-py-isolated",
7196            ])],
7197        )));
7198        let report = unique.reconciler.reconcile(&[superset, sibling]).await;
7199        assert_eq!(report.started, 1);
7200        assert_eq!(
7201            report
7202                .allocations
7203                .iter()
7204                .find(|allocation| allocation.to_start == 1)
7205                .unwrap()
7206                .policy_id,
7207            PolicyId::from_u128(1)
7208        );
7209        assert!(
7210            !unique
7211                .events
7212                .events()
7213                .iter()
7214                .any(|event| matches!(event, LifecycleEvent::RoutingRefused { .. }))
7215        );
7216
7217        let mut overlap = Harness::simple(4, 1, "o/r");
7218        let report = overlap
7219            .reconciler
7220            .reconcile(&[policy(1, "o/r", 2), policy(2, "o/r", 2)])
7221            .await;
7222        assert_eq!(report.started, 0);
7223        assert!(overlap.events.events().iter().any(|event| matches!(
7224            event,
7225            LifecycleEvent::RoutingRefused {
7226                reason: RoutingRefusal::OverlappingProfiles,
7227                count: 1,
7228                ..
7229            }
7230        )));
7231        assert!(
7232            RoutingRefusal::OverlappingProfiles
7233                .to_string()
7234                .contains("repair")
7235        );
7236    }
7237
7238    // =======================================================================
7239    // Failure paths
7240    // =======================================================================
7241
7242    #[tokio::test]
7243    async fn a_failed_launch_stops_the_run_and_is_reported_without_free_text() {
7244        let launcher = Arc::new(FakeLauncher::new());
7245        launcher.fail_next(FailureReason::Other("token ghp_0123456789abcdef".into()));
7246        let mut harness = Harness::build(
7247            host_with(4),
7248            Arc::clone(&launcher),
7249            Arc::new(FakeDemand::ready(3, &repo("acme/app"))),
7250            Arc::new(InProcessAllocationLock::new()),
7251        );
7252
7253        let report = harness
7254            .reconciler
7255            .reconcile(&[policy(1, "acme/app", 4)])
7256            .await;
7257        assert_eq!(report.started, 0);
7258        assert_eq!(report.allocations[0].to_start, 3, "the decision stands");
7259
7260        let failures: Vec<&'static str> = harness
7261            .events
7262            .events()
7263            .into_iter()
7264            .filter_map(|event| match event {
7265                LifecycleEvent::RunnerStartFailed { reason, .. } => Some(reason),
7266                _ => None,
7267            })
7268            .collect();
7269        assert_eq!(failures, vec!["other"]);
7270        assert!(
7271            !failures[0].contains("ghp_"),
7272            "an event carried a `FailureReason::Other` detail verbatim"
7273        );
7274    }
7275
7276    /// The host-fitness preflight: while a required host check fails, the pass
7277    /// still decides what it would start, but nothing reaches the inner
7278    /// launcher -- so nothing is registered with GitHub -- and the event names
7279    /// `host_unfit`. Clearing the flag lets the next pass start runners.
7280    #[tokio::test]
7281    async fn an_unfit_host_registers_no_runner_and_reports_host_unfit() {
7282        let launcher = Arc::new(FakeLauncher::new());
7283        let unfit = Arc::new(AtomicBool::new(true));
7284        let events = Arc::new(EventLog::new());
7285        let mut reconciler = Reconciler::new(
7286            host_with(4),
7287            ReconcilerPorts {
7288                demand: Arc::new(FakeDemand::ready(2, &repo("acme/app"))),
7289                launcher: Arc::new(FitnessGatedLauncher::new(
7290                    Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
7291                    Arc::clone(&unfit),
7292                )),
7293                lock: Arc::new(InProcessAllocationLock::new()),
7294                directory: Arc::new(FakeDirectory::default()),
7295                clock: Arc::new(FakeClock::default()) as Arc<dyn Clock>,
7296                jitter: Arc::new(NoJitter) as Arc<dyn Jitter>,
7297                events: Arc::clone(&events) as Arc<dyn EventSink>,
7298            },
7299        );
7300        let policies = [policy(1, "acme/app", 4)];
7301
7302        let report = reconciler.reconcile(&policies).await;
7303        assert_eq!(report.started, 0);
7304        assert_eq!(launcher.launches(), 0, "the inner launcher must not run");
7305        let reasons: Vec<&'static str> = events
7306            .events()
7307            .into_iter()
7308            .filter_map(|event| match event {
7309                LifecycleEvent::RunnerStartFailed { reason, .. } => Some(reason),
7310                _ => None,
7311            })
7312            .collect();
7313        assert_eq!(reasons, ["host_unfit"]);
7314
7315        unfit.store(false, Ordering::Release);
7316        let report = reconciler.reconcile(&policies).await;
7317        assert_eq!(report.started, 2);
7318        assert_eq!(launcher.launches(), 2);
7319    }
7320
7321    #[tokio::test]
7322    async fn a_held_allocation_lock_starts_nothing_and_says_so() {
7323        let launcher = Arc::new(FakeLauncher::new());
7324        let mut harness = Harness::build(
7325            host_with(4),
7326            Arc::clone(&launcher),
7327            Arc::new(FakeDemand::ready(3, &repo("acme/app"))),
7328            Arc::new(HeldLock),
7329        );
7330
7331        let report = harness
7332            .reconciler
7333            .reconcile(&[policy(1, "acme/app", 4)])
7334            .await;
7335        assert_eq!(report.started, 0);
7336        assert_eq!(
7337            report.deferred, 3,
7338            "three runners were granted and none was created; `deferred` counts grants, \
7339             not policies -- it reported `1` when a policy that launched two of five and \
7340             then lost the lock had left three unstarted"
7341        );
7342        assert_eq!(launcher.launches(), 0);
7343        assert_eq!(harness.events.count_of("allocation_deferred"), 1);
7344        assert!(
7345            harness
7346                .events
7347                .events()
7348                .iter()
7349                .any(|event| matches!(event, LifecycleEvent::AllocationDeferred { count: 3, .. })),
7350            "the event carries the same number the report does"
7351        );
7352        assert_eq!(
7353            report.allocations.len(),
7354            1,
7355            "the intent is still reported, so an operator staring at a queue sees why \
7356             nothing started"
7357        );
7358    }
7359
7360    #[tokio::test]
7361    async fn a_foreign_or_draining_policy_is_reported_by_name_and_polls_nothing() {
7362        let mut harness = Harness::simple(8, 5, "acme/app");
7363
7364        let foreign = fixtures::policy()
7365            .id(PolicyId::from_u128(1))
7366            .repository("acme/app")
7367            .host(HostId::from_u128(0xdead))
7368            .autoscale("office", 4)
7369            .active()
7370            .build();
7371        let mut draining = policy(2, "acme/app", 4);
7372        draining.request_disable().unwrap();
7373
7374        let report = harness.reconciler.reconcile(&[foreign, draining]).await;
7375
7376        assert_eq!(report.started, 0);
7377        assert_eq!(
7378            harness.demand.polls().len(),
7379            0,
7380            "neither can act on an answer"
7381        );
7382        let factors: Vec<LimitingFactor> = report
7383            .allocations
7384            .iter()
7385            .map(|a| a.limiting_factor)
7386            .collect();
7387        assert!(factors.contains(&LimitingFactor::ForeignHost));
7388        assert!(factors.contains(&LimitingFactor::NotReconciling));
7389    }
7390
7391    #[tokio::test]
7392    async fn an_unreadable_repository_list_makes_the_target_unreadable_not_empty() {
7393        // Polling a scope nobody chose would report a demand number for the
7394        // wrong set of repositories, which is worse than reporting nothing.
7395        #[derive(Debug)]
7396        struct BrokenDirectory;
7397
7398        #[async_trait::async_trait]
7399        impl RepositoryDirectory for BrokenDirectory {
7400            async fn repositories(&self, _org: &Org) -> Result<Vec<OwnerRepo>, InventoryError> {
7401                Err(InventoryError::Cancelled)
7402            }
7403        }
7404
7405        let launcher = Arc::new(FakeLauncher::new());
7406        let events = Arc::new(EventLog::new());
7407        let mut reconciler = Reconciler::new(
7408            host_with(4),
7409            ReconcilerPorts {
7410                demand: Arc::new(FakeDemand::default()),
7411                launcher: Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
7412                lock: Arc::new(InProcessAllocationLock::new()),
7413                directory: Arc::new(BrokenDirectory),
7414                clock: Arc::new(FakeClock::default()),
7415                jitter: Arc::new(NoJitter),
7416                events: Arc::clone(&events) as Arc<dyn EventSink>,
7417            },
7418        );
7419
7420        let org_policy = fixtures::policy()
7421            .id(PolicyId::from_u128(1))
7422            .organization("acme")
7423            .autoscale("home", 4)
7424            .active()
7425            .build();
7426
7427        let report = reconciler.reconcile(&[org_policy]).await;
7428        assert_eq!(report.started, 0);
7429        assert_eq!(report.unreadable, vec![PolicyId::from_u128(1)]);
7430        assert_eq!(events.count_of("target_unreadable"), 1);
7431    }
7432
7433    // =======================================================================
7434    // What the events may carry
7435    // =======================================================================
7436
7437    /// One value of every [`LifecycleEvent`] variant.
7438    ///
7439    /// Hand-written, and what keeps it honest is the wildcard-free `match` in
7440    /// [`LifecycleEvent::name`]: adding a variant stops that compiling and puts
7441    /// the author here. The same residual `b1` records for `FailureReason::ALL`
7442    /// applies — an author who writes the `name` arm and forgets this list gets
7443    /// a green suite with the variant unscanned.
7444    fn every_event() -> Vec<LifecycleEvent> {
7445        let policy = PolicyId::from_u128(0xabcd_ef01);
7446        let attempt = AttemptId::from_u128(0x1234_5678);
7447        vec![
7448            LifecycleEvent::DemandObserved {
7449                policy,
7450                demand: u32::MAX,
7451                not_matched: u32::MAX,
7452                unresolvable: u32::MAX,
7453                complete: false,
7454            },
7455            LifecycleEvent::TargetUnreadable {
7456                policy,
7457                reason: unreadable_reason(&RefreshState::Failed {
7458                    status: Some(500),
7459                    message: "Authorization: Bearer ghp_0123456789abcdefghijklmnopqrstuvwxyz"
7460                        .into(),
7461                }),
7462            },
7463            LifecycleEvent::Allocated {
7464                policy,
7465                demand: u32::MAX,
7466                desired: u16::MAX,
7467                active_owned: 7,
7468                headroom: 9,
7469                to_start: 2,
7470                limiting: LimitingFactor::HostCapacity,
7471            },
7472            LifecycleEvent::MonitorOnlySkipped { policy },
7473            LifecycleEvent::RunnerStarted { policy, attempt },
7474            LifecycleEvent::RunnerStartFailed {
7475                policy,
7476                reason: failure_reason_kind(&FailureReason::Other(
7477                    "Authorization: Bearer ghp_0123456789abcdefghijklmnopqrstuvwxyz".into(),
7478                )),
7479            },
7480            LifecycleEvent::AllocationDeferred { policy, count: 4 },
7481            LifecycleEvent::AttemptsUnreadable {
7482                reason: failure_reason_kind(&FailureReason::Other(
7483                    "x-api-key: ghp_0123456789abcdefghijklmnopqrstuvwxyz".into(),
7484                )),
7485            },
7486            LifecycleEvent::HostCapacityUnreadable {
7487                reason: HostCapacityError::MissingHost,
7488            },
7489            LifecycleEvent::HostCapacityUnreadable {
7490                reason: HostCapacityError::JournalUnreadable,
7491            },
7492            LifecycleEvent::AttemptCleanFailed {
7493                policy,
7494                attempt,
7495                reason: failure_reason_kind(&FailureReason::ProcessExitedUnexpectedly),
7496            },
7497            LifecycleEvent::AttemptCleaned {
7498                policy,
7499                attempt,
7500                outcome: OutcomeKind::IdleExit,
7501            },
7502            LifecycleEvent::ScaleDownRefused { policy, attempt },
7503            LifecycleEvent::PollScheduled {
7504                retry_in_ms: 900_000,
7505                pace: PollPace::RateLimited {
7506                    kind: RateLimitKind::Primary,
7507                },
7508            },
7509        ]
7510    }
7511
7512    /// `e1`'s Definition of Done: *"No emitted event contains a token, a JIT
7513    /// blob, or a credential header."*
7514    ///
7515    /// Asserted by rendering every variant and putting the result through `d1`'s
7516    /// own scrubber: if any of it looked like a credential to the redactor that
7517    /// guards the log file, the round trip would not be the identity. The
7518    /// positive control at the bottom is what stops that assertion passing
7519    /// because the scrubber is asleep.
7520    #[test]
7521    fn no_emitted_event_can_carry_a_credential() {
7522        use runner_manager_platform::logging::redact;
7523
7524        for event in every_event() {
7525            let displayed = event.to_string();
7526            assert_eq!(
7527                redact(&displayed),
7528                displayed,
7529                "`{}` renders something `d1`'s sink would have to redact",
7530                event.name()
7531            );
7532
7533            let debugged = format!("{event:?}");
7534            assert_eq!(
7535                redact(&debugged),
7536                debugged,
7537                "`{}`'s Debug renders something `d1`'s sink would have to redact",
7538                event.name()
7539            );
7540        }
7541
7542        // The control: the scrubber is awake, and would have caught a credential
7543        // had one been there.
7544        let secret = "Authorization: Bearer ghp_0123456789abcdefghijklmnopqrstuvwxyz";
7545        assert_ne!(
7546            redact(secret),
7547            secret,
7548            "the scan above proves nothing if `redact` no longer recognises a credential"
7549        );
7550    }
7551
7552    #[test]
7553    fn every_field_name_this_sink_emits_is_one_d1_allows() {
7554        use runner_manager_platform::logging::is_field_allowed;
7555
7556        // The names `TracingEvents` writes. Kept beside the sink rather than
7557        // derived from it, because a derived list would move with the code and
7558        // assert nothing.
7559        for field in [
7560            "event",
7561            "policy_id",
7562            "attempt_id",
7563            "attempt_state",
7564            "demand",
7565            "desired",
7566            "capacity",
7567            "headroom",
7568            "count",
7569            "reason",
7570            "outcome",
7571            "mode",
7572            "lock",
7573            "retry_in_ms",
7574            "state",
7575        ] {
7576            assert!(
7577                is_field_allowed(field),
7578                "`{field}` is not on `d1`'s allow-list, so this sink would emit \
7579                 `[redacted]` in its place and the line would lose its meaning"
7580            );
7581        }
7582    }
7583
7584    #[test]
7585    fn every_failure_reason_has_a_credential_free_kind() {
7586        for reason in FailureReason::ALL {
7587            let kind = failure_reason_kind(&reason);
7588            assert!(!kind.is_empty());
7589            assert!(
7590                kind.chars().all(|c| c.is_ascii_lowercase() || c == '_'),
7591                "`{kind}` is not a fixed identifier"
7592            );
7593        }
7594        assert_eq!(
7595            failure_reason_kind(&FailureReason::Other("ghp_secret".into())),
7596            "other",
7597            "the detail of an `Other` reason never reaches an event"
7598        );
7599        let categories = [
7600            IsolationProviderFailure::NotInstalled,
7601            IsolationProviderFailure::PermissionDenied,
7602            IsolationProviderFailure::ImageUnavailableOrIncompatible,
7603            IsolationProviderFailure::DiskQuotaUnavailable,
7604            IsolationProviderFailure::RuntimeOperationFailed,
7605        ];
7606        let mut kinds = std::collections::BTreeSet::new();
7607        for category in categories {
7608            let kind = failure_reason_kind(&FailureReason::IsolationProvider(category));
7609            assert_ne!(kind, "other");
7610            assert!(!kind.contains("secret"));
7611            kinds.insert(kind);
7612        }
7613        assert_eq!(kinds.len(), categories.len());
7614    }
7615
7616    // =======================================================================
7617    // The two tripwires
7618    // =======================================================================
7619
7620    /// One source file's production half, with comment lines dropped.
7621    ///
7622    /// Both exclusions are `c4`'s, and load-bearing for the same reasons. The
7623    /// **test module** goes because the tests in it legitimately name the shapes
7624    /// they forbid — this module's own positive control is a literal
7625    /// `async fn acquire_jobs`, which would accuse the file of the thing it is
7626    /// proving it does not do. The **comments** go because this module's
7627    /// documentation explains the seam at length and has to name what does not
7628    /// exist in order to say why; a scan that forbade the explanation is a scan
7629    /// that gets the explanation deleted.
7630    fn production_half_of(source: &str) -> String {
7631        let production = source
7632            .split_once("\n#[cfg(test)]")
7633            .map_or(source, |(production, _)| production);
7634        production
7635            .lines()
7636            .filter(|line| !line.trim_start().starts_with("//"))
7637            .collect::<Vec<_>>()
7638            .join("\n")
7639    }
7640
7641    /// This file's own production half.
7642    fn this_file_above_its_tests_without_prose() -> String {
7643        production_half_of(include_str!("reconcile.rs"))
7644    }
7645
7646    /// The one normalisation both halves of the scan use.
7647    ///
7648    /// # This is a second copy of `crates/github/src/demand.rs`, deliberately
7649    ///
7650    /// `production_half_of`, this function, [`FORBIDDEN`] and
7651    /// `forbidden_shape_in` together duplicate `demand.rs:1530-1619`. Sharing
7652    /// them would mean putting them in `crates/testkit`, which `e1` does not
7653    /// own, so the copy was the only option available to this task.
7654    ///
7655    /// **It is worth consolidating later, and here is the specific hazard.**
7656    /// The last defect in `c4`'s copy was two spellings of "the same"
7657    /// normalisation drifting apart — the haystack lower-cased and the needle
7658    /// not — which made three of its seven assertions vacuously true from the
7659    /// day they were written. Two copies is the same hazard one level up. The
7660    /// mitigation inside *this* copy is that one function serves both the scan
7661    /// and its positive control, so a normaliser that stops matching fails the
7662    /// control loudly rather than passing the scan silently; what that cannot
7663    /// catch is this copy and `c4`'s diverging from each other.
7664    fn normalise_for_scan(text: &str) -> String {
7665        text.to_ascii_lowercase().replace(['_', ' '], "")
7666    }
7667
7668    /// The Actions-service call this design has no equivalent of, plus the
7669    /// shapes an implementer would invent in its place.
7670    ///
7671    /// Spelled in halves so that no needle ever appears whole in the text being
7672    /// scanned, and keyed to `fn`/`struct` so that the prose above may keep
7673    /// explaining why there is no reservation. `c4` records both trades at
7674    /// length; this list is its counterpart one layer up. Note that the
7675    /// allocation lock's own `fn acquire` is deliberately *not* matched: the
7676    /// needle is `acquire`-a-**job**, and a lock is not one.
7677    const FORBIDDEN: &[&str] = &[
7678        concat!("fn ", "acquire", "_job"),
7679        concat!("fn ", "claim", "_job"),
7680        concat!("fn ", "lease", "_job"),
7681        concat!("fn ", "reserve", "_job"),
7682        concat!("fn ", "ack", "nowledge"),
7683        concat!("struct ", "Job", "Lease"),
7684        concat!("struct ", "Job", "Claim"),
7685        concat!("struct ", "Job", "Reservation"),
7686    ];
7687
7688    fn forbidden_shape_in(source: &str) -> Option<&'static str> {
7689        let haystack = normalise_for_scan(source);
7690        FORBIDDEN
7691            .iter()
7692            .copied()
7693            .find(|forbidden| haystack.contains(&normalise_for_scan(forbidden)))
7694    }
7695
7696    /// `e1`'s Definition of Done: *"No reservation, claim, lease, or acquisition
7697    /// call exists in the crate; a test or review note records that this is
7698    /// deliberate rather than missing."*
7699    ///
7700    /// **Deliberate, not missing.** The scale-set model let a listener call
7701    /// `AcquireJobs` to claim an assignment before scaling; the REST path has no
7702    /// equivalent, so demand is advisory and two hosts serving the same labels
7703    /// can both start a runner for one queued run. Adding a local reservation
7704    /// table would not remove that — the other host cannot see it — it would
7705    /// only hide the surplus case from the tests that measure it. The three
7706    /// controls that actually bound it are host-scoped routing labels,
7707    /// `max_capacity`, and `host_capacity`, and the last two are enforced in
7708    /// this file.
7709    ///
7710    /// The scan is a tripwire on the obvious shape rather than a proof: a
7711    /// reservation reached through a trait method or a differently-named helper
7712    /// would walk past it. Review is the primary control, exactly as `c4` states
7713    /// for its own copy.
7714    ///
7715    /// # It scans the crate, because the bullet says "in the crate"
7716    ///
7717    /// It used to scan this file alone while quoting a crate-wide claim, which
7718    /// left `lifecycle.rs` — `e3`, the launcher, and by far the likeliest place
7719    /// for someone to "fix" the surplus-runner case with a local lease — covered
7720    /// by nothing. Reading another owner's file is not editing it, so ownership
7721    /// was never the obstacle.
7722    ///
7723    /// The walk below is `c4`'s, and it **recurses** for the reason `c4`
7724    /// records: a module directory (`src/reconcile/mod.rs`) arrives as an entry
7725    /// that does not end in `.rs`, so a flat filter drops it and takes every
7726    /// file underneath with it, leaving the scan passing over files it covers
7727    /// by nothing at all. The listed-versus-on-disk assertion is what stops
7728    /// `SOURCES` going stale the moment `e2` or `e3` adds a module.
7729    #[test]
7730    fn nothing_in_this_crate_reserves_or_claims_a_job() {
7731        const SOURCES: &[(&str, &str)] = &[
7732            ("dependency_caches.rs", include_str!("dependency_caches.rs")),
7733            ("docker_cleanup.rs", include_str!("docker_cleanup.rs")),
7734            (
7735                "docker_cleanup/http.rs",
7736                include_str!("docker_cleanup/http.rs"),
7737            ),
7738            (
7739                "docker_cleanup/proxy.rs",
7740                include_str!("docker_cleanup/proxy.rs"),
7741            ),
7742            (
7743                "docker_cleanup/tests.rs",
7744                include_str!("docker_cleanup/tests.rs"),
7745            ),
7746            ("lib.rs", include_str!("lib.rs")),
7747            ("lifecycle.rs", include_str!("lifecycle.rs")),
7748            (
7749                "lifecycle/windows_hyperv.rs",
7750                include_str!("lifecycle/windows_hyperv.rs"),
7751            ),
7752            ("oci.rs", include_str!("oci.rs")),
7753            ("package.rs", include_str!("package.rs")),
7754            ("reconcile.rs", include_str!("reconcile.rs")),
7755        ];
7756
7757        fn walk(directory: &std::path::Path, prefix: &str, found: &mut Vec<String>) {
7758            for entry in std::fs::read_dir(directory).expect("the crate's own src/ is readable") {
7759                let entry = entry.expect("a readable directory entry");
7760                let name = entry.file_name().to_string_lossy().into_owned();
7761                // `/`-joined, which is what `include_str!` takes on every
7762                // platform, so the two sides compare directly.
7763                let joined = if prefix.is_empty() {
7764                    name.clone()
7765                } else {
7766                    format!("{prefix}/{name}")
7767                };
7768                if entry.path().is_dir() {
7769                    walk(&entry.path(), &joined, found);
7770                } else if name.ends_with(".rs") {
7771                    found.push(joined);
7772                }
7773            }
7774        }
7775
7776        let mut listed: Vec<&str> = SOURCES.iter().map(|(name, _)| *name).collect();
7777        listed.sort_unstable();
7778        let mut on_disk = Vec::new();
7779        walk(
7780            std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/src")),
7781            "",
7782            &mut on_disk,
7783        );
7784        on_disk.sort_unstable();
7785        assert_eq!(
7786            listed, on_disk,
7787            "a source file was added or removed; this scan claims to cover the whole crate \
7788             and a stale list makes that claim false"
7789        );
7790
7791        for (name, source) in SOURCES {
7792            assert_eq!(
7793                forbidden_shape_in(&production_half_of(source)),
7794                None,
7795                "{name} names a forbidden shape: there is no `AcquireJobs` equivalent over \
7796                 REST, and a local lease coordinates this host with itself and with nothing \
7797                 else. If an owner decision restored one, that decision belongs in this \
7798                 module's documentation and in this test before it belongs in the code"
7799            );
7800        }
7801
7802        // The control: the scan can see a shape when there is one, through the
7803        // same matcher the loop above uses.
7804        assert!(
7805            forbidden_shape_in("async fn acquire_jobs(&self) -> Vec<Job> { todo!() }").is_some(),
7806            "the scan above proves nothing if the needles no longer match"
7807        );
7808    }
7809
7810    /// This module **applies** `b1`'s label predicate and implements none of it.
7811    ///
7812    /// The counterpart to `c4`'s scan over `crates/github/src/demand.rs`, and it
7813    /// checks the opposite thing, because the two modules sit on opposite sides
7814    /// of the same seam. `c4` builds a `RunsOn` per queued job and must name no
7815    /// `RoutingLabels`; this module holds the policy whose labels decide, so it
7816    /// must call `RoutingLabels::tally` and must not re-derive what that call
7817    /// answers.
7818    ///
7819    /// So the scan is in two halves:
7820    ///
7821    /// * **Present.** `DemandTally` has to appear, because [`demand_for`]
7822    ///   returns one. A production half that named it nowhere would mean the
7823    ///   filtering had been dropped and every queued job in a watched repository
7824    ///   was driving this policy toward `max_capacity` again.
7825    /// * **Absent.** The vocabulary of a *second* implementation. `b1` names the
7826    ///   three outcomes of matching one job; this module consumes the aggregate
7827    ///   and never a single job's verdict, so naming `RunsOnMatch` or
7828    ///   `UnresolvableRunsOn` here means a `match` on an outcome that
7829    ///   `RoutingLabels::tally` has already decided — which is how two copies of
7830    ///   a predicate start.
7831    ///
7832    /// Like the needles in `nothing_in_this_module_reserves_or_claims_a_job`,
7833    /// this is a tripwire on the obvious shape rather than a proof: a hand-rolled
7834    /// comparison of raw label strings that never names a `policy` type would
7835    /// walk past it. Stated rather than implied, for the same reason it is
7836    /// stated there.
7837    #[test]
7838    fn the_label_predicate_is_b1s_and_this_module_only_applies_it() {
7839        let production = this_file_above_its_tests_without_prose();
7840
7841        assert!(
7842            production.contains("DemandTally"),
7843            "the reconciliation loop must tally queued jobs against this policy's routing \
7844             labels. A production half that named `DemandTally` nowhere would mean the \
7845             label filtering had been removed, and a repository whose jobs target \
7846             `ubuntu-latest` would drive its policy toward `max_capacity` again"
7847        );
7848
7849        for second_implementation in ["RunsOnMatch", "UnresolvableRunsOn"] {
7850            assert!(
7851                !production.contains(second_implementation),
7852                "the reconciliation loop names `{second_implementation}`, which is the \
7853                 vocabulary of deciding one job's `runs-on` -- and `RoutingLabels::tally` \
7854                 has already decided it. This module applies the predicate and does not \
7855                 re-implement it; if an owner decision changed that, it belongs in this \
7856                 module's documentation and in this test before it belongs in the code"
7857            );
7858        }
7859    }
7860
7861    /// The demand this module clamps is the *matched* count, and a job this host
7862    /// cannot serve is not demand.
7863    ///
7864    /// The behaviour the whole reversal was for, asserted end to end through
7865    /// `demand_for` rather than through `b1`'s predicate in isolation: a policy
7866    /// carrying this host's labels, a reading holding some of its jobs and some
7867    /// of somebody else's, and the three counts kept apart.
7868    #[test]
7869    fn demand_is_the_queued_jobs_this_policy_can_actually_serve() {
7870        let policy = policy(1, "acme/app", 10);
7871        let reading = QueuedDemand::of(
7872            repo("acme/app"),
7873            [
7874                fixtures::queued_job(&[HOST_LABEL]),
7875                fixtures::queued_job(&[HOST_LABEL]),
7876                fixtures::queued_job(&["ubuntu-latest"]),
7877                fixtures::unresolvable_job(),
7878            ],
7879        );
7880
7881        let tally = demand_for(&policy, &reading);
7882
7883        assert_eq!(
7884            tally.demand(),
7885            2,
7886            "only the jobs whose required labels this policy carries are demand"
7887        );
7888        assert_eq!(
7889            tally.not_matched, 1,
7890            "a `ubuntu-latest` job is somebody else's work; counting it would start a \
7891             runner that idles until it times out"
7892        );
7893        assert_eq!(
7894            tally.unresolvable.len(),
7895            1,
7896            "an unresolvable `runs-on` is never demand and never discarded"
7897        );
7898    }
7899
7900    /// A repository target reads its own repository; an organization target
7901    /// reads the whole scope.
7902    #[test]
7903    fn an_organization_policy_tallies_every_repository_its_scope_covers() {
7904        let mut per_repository = BTreeMap::new();
7905        per_repository.insert(repo("acme/left"), fixtures::queued_jobs(&[HOST_LABEL], 3));
7906        per_repository.insert(repo("acme/right"), fixtures::queued_jobs(&[HOST_LABEL], 4));
7907        let reading = QueuedDemand::new(per_repository);
7908
7909        let repository_policy = policy(1, "acme/left", 10);
7910        assert_eq!(
7911            demand_for(&repository_policy, &reading).demand(),
7912            3,
7913            "a repository target reads its own repository's queue and not the aggregate"
7914        );
7915
7916        let org_policy = fixtures::policy()
7917            .id(PolicyId::from_u128(2))
7918            .organization("acme")
7919            .autoscale("home", 10)
7920            .active()
7921            .build();
7922        assert_eq!(
7923            demand_for(&org_policy, &reading).demand(),
7924            7,
7925            "an organization policy serves any repository in its scope, so its demand is \
7926             the whole aggregate's"
7927        );
7928    }
7929
7930    #[test]
7931    fn the_accepted_over_count_is_bounded_by_the_two_ceilings_and_nothing_else() {
7932        // The owner decision accepts that a repository whose jobs only target
7933        // `ubuntu-latest` still drives its policy toward `max_capacity`. What
7934        // stops that being unbounded is exactly what stops any other demand
7935        // being unbounded, which is asserted here rather than assumed.
7936        let host = host_with(2);
7937        let policy = policy(1, "acme/app", 5);
7938        let attempts: Vec<RunnerAttempt> = Vec::new();
7939        let mut allocator = HostAllocator::from_attempts(&host, &attempts);
7940
7941        let allocation = allocator.allocate(&policy, u32::MAX);
7942        assert_eq!(allocation.desired, 5, "max_capacity beats reported demand");
7943        assert_eq!(allocation.to_start, 2, "host_capacity beats max_capacity");
7944        assert_eq!(allocation.limiting_factor, LimitingFactor::HostCapacity);
7945    }
7946
7947    /// A director standing in for the coordinator's launches: a fixed count
7948    /// per policy, and every attempt it was told about.
7949    #[derive(Debug, Default)]
7950    struct FixedDirector {
7951        directed: Mutex<BTreeMap<PolicyId, u32>>,
7952        started: Mutex<Vec<(PolicyId, AttemptId)>>,
7953        failed: Mutex<Vec<(PolicyId, Option<AttemptId>)>>,
7954        /// What each look at the older launches answers; the last one repeats.
7955        older: Mutex<std::collections::VecDeque<usize>>,
7956        passed_over: AtomicUsize,
7957        /// Each policy's place among the launches.
7958        orders: Mutex<BTreeMap<PolicyId, u64>>,
7959    }
7960
7961    impl DemandDirector for FixedDirector {
7962        fn directed_demand(&self, policy: &ScalePolicy) -> Option<u32> {
7963            self.directed.lock().unwrap().get(&policy.id).copied()
7964        }
7965
7966        fn launched(&self, policy: &ScalePolicy, attempt: AttemptId) {
7967            self.started.lock().unwrap().push((policy.id, attempt));
7968        }
7969
7970        fn start_failed(&self, policy: &ScalePolicy, attempt: Option<AttemptId>) {
7971            self.failed.lock().unwrap().push((policy.id, attempt));
7972        }
7973
7974        fn next_waiting(&self, policy: &ScalePolicy) -> Option<u64> {
7975            self.orders.lock().unwrap().get(&policy.id).copied()
7976        }
7977
7978        fn older_waiting(&self, _policy: &ScalePolicy) -> usize {
7979            let mut older = self.older.lock().unwrap();
7980            if older.len() > 1 {
7981                older.pop_front().unwrap_or(0)
7982            } else {
7983                older.front().copied().unwrap_or(0)
7984            }
7985        }
7986
7987        fn pass_over_older(&self, _policy: &ScalePolicy) {
7988            self.passed_over.fetch_add(1, Ordering::SeqCst);
7989            self.older.lock().unwrap().clear();
7990        }
7991    }
7992
7993    /// A launcher that holds every runner in its bring-up until as many others
7994    /// (or the test) are at the barrier.
7995    #[derive(Debug)]
7996    struct HeldBringUp {
7997        inner: Arc<FakeLauncher>,
7998        barrier: Arc<tokio::sync::Barrier>,
7999        fail: Option<FailureReason>,
8000        waits: Mutex<Vec<ReservationWaits>>,
8001    }
8002
8003    #[async_trait::async_trait]
8004    impl RunnerLauncher for HeldBringUp {
8005        async fn host_capacity(&self, host: &Host) -> Result<NonZeroU16, HostCapacityError> {
8006            self.inner.host_capacity(host).await
8007        }
8008
8009        async fn supervise(
8010            &self,
8011            policy: &ScalePolicy,
8012        ) -> Result<Vec<ReplacementIntent>, LaunchFailure> {
8013            self.inner.supervise(policy).await
8014        }
8015
8016        async fn attempts(&self) -> Result<Vec<RunnerAttempt>, LaunchFailure> {
8017            self.inner.attempts().await
8018        }
8019
8020        async fn launch(&self, request: LaunchRequest<'_>) -> Result<RunnerAttempt, LaunchFailure> {
8021            self.inner.launch(request).await
8022        }
8023
8024        async fn finish_launch(
8025            &self,
8026            _policy: &ScalePolicy,
8027            attempt: RunnerAttempt,
8028            waits: ReservationWaits,
8029        ) -> Result<RunnerAttempt, LaunchFailure> {
8030            self.waits.lock().unwrap().push(waits);
8031            self.barrier.wait().await;
8032            match &self.fail {
8033                Some(reason) => Err(LaunchFailure::new(reason.clone())),
8034                None => Ok(attempt),
8035            }
8036        }
8037
8038        async fn clean(&self, attempt: AttemptId) -> Result<(), LaunchFailure> {
8039            self.inner.clean(attempt).await
8040        }
8041    }
8042
8043    fn directed_reconciler(
8044        capacity: u16,
8045        launcher: Arc<dyn RunnerLauncher>,
8046        lock: Arc<dyn AllocationLock>,
8047        director: Arc<FixedDirector>,
8048    ) -> Reconciler {
8049        Reconciler::new(
8050            host_with(capacity),
8051            ReconcilerPorts {
8052                demand: Arc::new(FakeDemand::ready(0, &repo("acme/app"))),
8053                launcher,
8054                lock,
8055                directory: Arc::new(FakeDirectory::default()),
8056                clock: Arc::new(FakeClock::default()),
8057                jitter: Arc::new(NoJitter),
8058                events: Arc::new(NoEvents),
8059            },
8060        )
8061        .with_director(director as Arc<dyn DemandDirector>)
8062    }
8063
8064    /// Two targets' runners are being brought up at the same time: each is at
8065    /// the barrier with the allocation lock released, or neither gets there.
8066    #[tokio::test]
8067    async fn two_targets_bring_up_their_runners_at_once() {
8068        let fake = Arc::new(FakeLauncher::new());
8069        let lock: Arc<dyn AllocationLock> = Arc::new(InProcessAllocationLock::new());
8070        let barrier = Arc::new(tokio::sync::Barrier::new(2));
8071        let held = Arc::new(HeldBringUp {
8072            inner: Arc::clone(&fake),
8073            barrier,
8074            fail: None,
8075            waits: Mutex::default(),
8076        });
8077        let director = Arc::new(FixedDirector::default());
8078        let (one, two) = (policy(1, "acme/app", 2), policy(2, "acme/other", 2));
8079        director.directed.lock().unwrap().insert(one.id, 1);
8080        director.directed.lock().unwrap().insert(two.id, 1);
8081        let first = directed_reconciler(
8082            2,
8083            Arc::clone(&held) as _,
8084            Arc::clone(&lock),
8085            Arc::clone(&director),
8086        );
8087        let second = directed_reconciler(2, held as _, lock, director);
8088
8089        let (a, b) = tokio::time::timeout(Duration::from_secs(10), async {
8090            tokio::join!(
8091                first.start_directed(std::slice::from_ref(&one)),
8092                second.start_directed(std::slice::from_ref(&two)),
8093            )
8094        })
8095        .await
8096        .expect("neither waits for the other's bring-up");
8097        assert_eq!((a.started, b.started), (1, 1));
8098    }
8099
8100    /// No oversubscription: the slot is held from the reservation, while the
8101    /// runner is still being brought up and the lock is free.
8102    #[tokio::test]
8103    async fn a_runner_coming_up_holds_its_slot_against_another_target() {
8104        let fake = Arc::new(FakeLauncher::new());
8105        let lock: Arc<dyn AllocationLock> = Arc::new(InProcessAllocationLock::new());
8106        let barrier = Arc::new(tokio::sync::Barrier::new(2));
8107        let held = Arc::new(HeldBringUp {
8108            inner: Arc::clone(&fake),
8109            barrier: Arc::clone(&barrier),
8110            fail: None,
8111            waits: Mutex::default(),
8112        });
8113        let director = Arc::new(FixedDirector::default());
8114        let (one, two) = (policy(1, "acme/app", 2), policy(2, "acme/other", 2));
8115        director.directed.lock().unwrap().insert(one.id, 1);
8116        director.directed.lock().unwrap().insert(two.id, 1);
8117        let first = directed_reconciler(
8118            1,
8119            Arc::clone(&held) as _,
8120            Arc::clone(&lock),
8121            Arc::clone(&director),
8122        );
8123        let second = directed_reconciler(1, held as _, lock, director);
8124
8125        let (a, b) = tokio::time::timeout(Duration::from_secs(10), async {
8126            tokio::join!(first.start_directed(std::slice::from_ref(&one)), async {
8127                while fake.live_count() == 0 {
8128                    tokio::task::yield_now().await;
8129                }
8130                let report = second.start_directed(std::slice::from_ref(&two)).await;
8131                barrier.wait().await;
8132                report
8133            })
8134        })
8135        .await
8136        .expect("the first is let go");
8137        assert_eq!((a.started, b.started), (1, 0));
8138        assert_eq!(fake.live_count(), 1);
8139    }
8140
8141    /// The launch was given its runner at the reservation, and a bring-up that
8142    /// fails says so, rather than counting as started.
8143    #[tokio::test]
8144    async fn a_failed_bring_up_is_a_failed_start() {
8145        let fake = Arc::new(FakeLauncher::new());
8146        let held = Arc::new(HeldBringUp {
8147            inner: fake,
8148            barrier: Arc::new(tokio::sync::Barrier::new(1)),
8149            fail: Some(FailureReason::JitRequestFailed),
8150            waits: Mutex::default(),
8151        });
8152        let director = Arc::new(FixedDirector::default());
8153        let pool = policy(1, "acme/app", 2);
8154        director.directed.lock().unwrap().insert(pool.id, 1);
8155        let reconciler = directed_reconciler(
8156            2,
8157            held as _,
8158            Arc::new(InProcessAllocationLock::new()),
8159            Arc::clone(&director),
8160        );
8161
8162        let report = reconciler.start_directed(std::slice::from_ref(&pool)).await;
8163
8164        assert_eq!(report.started, 0);
8165        let started = director.started.lock().unwrap()[0].1;
8166        assert_eq!(
8167            *director.failed.lock().unwrap(),
8168            vec![(pool.id, Some(started))]
8169        );
8170    }
8171
8172    fn scripted_older(director: &FixedDirector, looks: &[(usize, usize)]) {
8173        *director.older.lock().unwrap() = looks
8174            .iter()
8175            .flat_map(|(value, count)| std::iter::repeat_n(*value, *count))
8176            .collect();
8177    }
8178
8179    /// Oldest first across targets: a younger launch reserves after the older
8180    /// ones, however long that takes while they are reserving, and says so in its
8181    /// timings.
8182    #[tokio::test(start_paused = true)]
8183    async fn a_younger_launch_reserves_after_older_ones_while_they_make_progress() {
8184        let held = Arc::new(HeldBringUp {
8185            inner: Arc::new(FakeLauncher::new()),
8186            barrier: Arc::new(tokio::sync::Barrier::new(1)),
8187            fail: None,
8188            waits: Mutex::default(),
8189        });
8190        let director = Arc::new(FixedDirector::default());
8191        let pool = policy(1, "acme/app", 3);
8192        director.directed.lock().unwrap().insert(pool.id, 1);
8193        let reconciler = directed_reconciler(
8194            5,
8195            Arc::clone(&held) as _,
8196            Arc::new(InProcessAllocationLock::new()),
8197            Arc::clone(&director),
8198        );
8199
8200        // Three older launches, each reserving just within the turn: four and a
8201        // half seconds in all, well over one turn, and nothing is passed over.
8202        let looks =
8203            (OLDER_LAUNCH_TURN.as_millis() * 3 / 4 / OLDER_LAUNCH_POLL.as_millis()) as usize;
8204        scripted_older(&director, &[(3, looks), (2, looks), (1, looks), (0, 1)]);
8205        let began = tokio::time::Instant::now();
8206        let report = reconciler.start_directed(std::slice::from_ref(&pool)).await;
8207        assert_eq!(report.started, 1);
8208        assert_eq!(
8209            began.elapsed(),
8210            OLDER_LAUNCH_POLL * u32::try_from(looks * 3).unwrap()
8211        );
8212        assert_eq!(director.passed_over.load(Ordering::SeqCst), 0);
8213        assert_eq!(
8214            held.waits.lock().unwrap()[0].order,
8215            OLDER_LAUNCH_POLL * u32::try_from(looks * 3).unwrap(),
8216            "the wait is in the launch's timings"
8217        );
8218    }
8219
8220    /// A pass concludes runners, so it is not held by a launch's bring-up: with a
8221    /// launch side, it reports on the launch and hands it over.
8222    #[tokio::test]
8223    async fn a_pass_hands_a_directed_launch_to_the_launch_side() {
8224        let fake = Arc::new(FakeLauncher::new());
8225        let director = Arc::new(FixedDirector::default());
8226        let pool = policy(1, "acme/app", 3);
8227        director.directed.lock().unwrap().insert(pool.id, 1);
8228        let signal = Arc::new(tokio::sync::Notify::new());
8229        let mut reconciler = directed_reconciler(
8230            5,
8231            Arc::clone(&fake) as _,
8232            Arc::new(InProcessAllocationLock::new()),
8233            Arc::clone(&director),
8234        )
8235        .with_launch_side(Arc::clone(&signal));
8236
8237        let report = reconciler.reconcile(std::slice::from_ref(&pool)).await;
8238
8239        assert_eq!(report.started, 0);
8240        assert_eq!(fake.live_count(), 0);
8241        assert_eq!(report.allocations[0].to_start, 1);
8242        tokio::time::timeout(Duration::from_secs(1), signal.notified())
8243            .await
8244            .expect("the launch side is told");
8245
8246        // Nothing to start: nothing to tell it.
8247        director.directed.lock().unwrap().insert(pool.id, 0);
8248        reconciler.reconcile(std::slice::from_ref(&pool)).await;
8249        assert!(
8250            tokio::time::timeout(Duration::from_millis(50), signal.notified())
8251                .await
8252                .is_err()
8253        );
8254    }
8255
8256    /// One that never reserves costs one wait, then nothing: a loop that cannot
8257    /// reserve holds no launch up for long, and not every later one again.
8258    #[tokio::test(start_paused = true)]
8259    async fn an_older_launch_that_cannot_reserve_is_passed_over_once() {
8260        let fake = Arc::new(FakeLauncher::new());
8261        let director = Arc::new(FixedDirector::default());
8262        let pool = policy(1, "acme/app", 3);
8263        director.directed.lock().unwrap().insert(pool.id, 1);
8264        let reconciler = directed_reconciler(
8265            5,
8266            Arc::clone(&fake) as _,
8267            Arc::new(InProcessAllocationLock::new()),
8268            Arc::clone(&director),
8269        );
8270
8271        scripted_older(&director, &[(1, 1)]);
8272        let began = tokio::time::Instant::now();
8273        let report = reconciler.start_directed(std::slice::from_ref(&pool)).await;
8274        assert_eq!(report.started, 1, "started anyway");
8275        assert!(began.elapsed() >= OLDER_LAUNCH_TURN);
8276        assert!(began.elapsed() < OLDER_LAUNCH_TURN + Duration::from_secs(1));
8277        assert_eq!(director.passed_over.load(Ordering::SeqCst), 1);
8278
8279        director.directed.lock().unwrap().insert(pool.id, 2);
8280        let began = tokio::time::Instant::now();
8281        let report = reconciler.start_directed(std::slice::from_ref(&pool)).await;
8282        assert_eq!(report.started, 1);
8283        assert_eq!(began.elapsed(), Duration::ZERO);
8284    }
8285
8286    /// A target's own profiles keep the order too: the oldest launch reserves
8287    /// first, one runner at a time.
8288    #[tokio::test]
8289    async fn a_targets_profiles_reserve_oldest_launch_first() {
8290        let director = Arc::new(FixedDirector::default());
8291        let (younger, older) = (policy(1, "acme/app", 3), policy(2, "acme/app", 3));
8292        director.directed.lock().unwrap().insert(younger.id, 1);
8293        director.directed.lock().unwrap().insert(older.id, 1);
8294        director.orders.lock().unwrap().insert(younger.id, 9);
8295        director.orders.lock().unwrap().insert(older.id, 4);
8296        let reconciler = directed_reconciler(
8297            5,
8298            Arc::new(FakeLauncher::new()) as _,
8299            Arc::new(InProcessAllocationLock::new()),
8300            Arc::clone(&director),
8301        );
8302
8303        let report = reconciler
8304            .start_directed(&[younger.clone(), older.clone()])
8305            .await;
8306
8307        assert_eq!(report.started, 2);
8308        let order: Vec<PolicyId> = director
8309            .started
8310            .lock()
8311            .unwrap()
8312            .iter()
8313            .map(|(policy, _)| *policy)
8314            .collect();
8315        assert_eq!(order, vec![older.id, younger.id]);
8316    }
8317
8318    /// Serves `pool` until told to stop, with `signal` and `stop` for the test.
8319    fn serving<'a>(
8320        reconciler: &'a Reconciler,
8321        pool: &ScalePolicy,
8322        signal: &'a tokio::sync::Notify,
8323        stop: tokio::sync::watch::Receiver<bool>,
8324    ) -> impl std::future::Future<Output = ()> + 'a {
8325        let pool = pool.clone();
8326        async move {
8327            let policies = move || vec![pool.clone()];
8328            reconciler.serve_launches(&policies, signal, stop).await;
8329        }
8330    }
8331
8332    /// The batch does not hold a launch back. Each bring-up waits at the barrier
8333    /// for the other, so the second must reach it while the first is coming up, or
8334    /// neither ever gets past.
8335    #[tokio::test]
8336    async fn a_launch_accepted_during_a_batch_starts_without_waiting_for_it() {
8337        let fake = Arc::new(FakeLauncher::new());
8338        let held = Arc::new(HeldBringUp {
8339            inner: Arc::clone(&fake),
8340            barrier: Arc::new(tokio::sync::Barrier::new(2)),
8341            fail: None,
8342            waits: Mutex::default(),
8343        });
8344        let director = Arc::new(FixedDirector::default());
8345        let pool = policy(1, "acme/app", 3);
8346        director.directed.lock().unwrap().insert(pool.id, 1);
8347        let reconciler = directed_reconciler(
8348            5,
8349            held as _,
8350            Arc::new(InProcessAllocationLock::new()),
8351            Arc::clone(&director),
8352        );
8353        let signal = tokio::sync::Notify::new();
8354        let (stop, stopped) = tokio::sync::watch::channel(false);
8355
8356        tokio::time::timeout(Duration::from_secs(10), async {
8357            tokio::join!(serving(&reconciler, &pool, &signal, stopped), async {
8358                signal.notify_one();
8359                while director.started.lock().unwrap().is_empty() {
8360                    tokio::task::yield_now().await;
8361                }
8362                director.directed.lock().unwrap().insert(pool.id, 2);
8363                signal.notify_one();
8364                while director.started.lock().unwrap().len() < 2 {
8365                    tokio::task::yield_now().await;
8366                }
8367                stop.send_replace(true);
8368            })
8369        })
8370        .await
8371        .expect("the second came up beside the first, and stopping ends it");
8372        assert_eq!(fake.live_count(), 2);
8373    }
8374
8375    /// Serving launches beside a batch changes no ceiling: the runner still
8376    /// coming up holds its slot.
8377    #[tokio::test]
8378    async fn a_launch_during_a_batch_takes_no_slot_that_batch_holds() {
8379        let fake = Arc::new(FakeLauncher::new());
8380        let held = Arc::new(HeldBringUp {
8381            inner: Arc::clone(&fake),
8382            barrier: Arc::new(tokio::sync::Barrier::new(2)),
8383            fail: None,
8384            waits: Mutex::default(),
8385        });
8386        let barrier = Arc::clone(&held.barrier);
8387        let director = Arc::new(FixedDirector::default());
8388        let pool = policy(1, "acme/app", 3);
8389        director.directed.lock().unwrap().insert(pool.id, 1);
8390        let reconciler = directed_reconciler(
8391            1,
8392            held as _,
8393            Arc::new(InProcessAllocationLock::new()),
8394            Arc::clone(&director),
8395        );
8396        let signal = tokio::sync::Notify::new();
8397        let (stop, stopped) = tokio::sync::watch::channel(false);
8398
8399        tokio::join!(serving(&reconciler, &pool, &signal, stopped), async {
8400            signal.notify_one();
8401            while director.started.lock().unwrap().is_empty() {
8402                tokio::task::yield_now().await;
8403            }
8404            director.directed.lock().unwrap().insert(pool.id, 2);
8405            signal.notify_one();
8406            for _ in 0..100 {
8407                tokio::task::yield_now().await;
8408            }
8409            assert_eq!(
8410                director.started.lock().unwrap().len(),
8411                1,
8412                "the host is full"
8413            );
8414            assert_eq!(fake.live_count(), 1);
8415            stop.send_replace(true);
8416            barrier.wait().await;
8417        });
8418    }
8419
8420    /// Nothing reserved waits for the rest of the round: here the next reservation
8421    /// cannot start until the first runner's bring-up has run, because an older
8422    /// launch elsewhere holds it for a whole turn.
8423    #[tokio::test(start_paused = true)]
8424    async fn a_reserved_runner_comes_up_before_the_next_reservation_ends() {
8425        let held = Arc::new(HeldBringUp {
8426            inner: Arc::new(FakeLauncher::new()),
8427            barrier: Arc::new(tokio::sync::Barrier::new(1)),
8428            fail: None,
8429            waits: Mutex::default(),
8430        });
8431        let director = Arc::new(FixedDirector::default());
8432        let pool = policy(1, "acme/app", 3);
8433        director.directed.lock().unwrap().insert(pool.id, 2);
8434        let reconciler = directed_reconciler(
8435            5,
8436            Arc::clone(&held) as _,
8437            Arc::new(InProcessAllocationLock::new()),
8438            Arc::clone(&director),
8439        );
8440        let signal = tokio::sync::Notify::new();
8441        let (stop, stopped) = tokio::sync::watch::channel(false);
8442
8443        // Nothing older at the first look, and one that never reserves at the next.
8444        scripted_older(&director, &[(0, 1), (1, 1)]);
8445        tokio::join!(serving(&reconciler, &pool, &signal, stopped), async {
8446            signal.notify_one();
8447            tokio::time::sleep(Duration::from_millis(1)).await;
8448            assert_eq!(
8449                held.waits.lock().unwrap().len(),
8450                1,
8451                "the first is coming up"
8452            );
8453            tokio::time::sleep(OLDER_LAUNCH_TURN + Duration::from_secs(1)).await;
8454            assert_eq!(held.waits.lock().unwrap().len(), 2);
8455            stop.send_replace(true);
8456        });
8457    }
8458
8459    /// A launch whose runner failed coming up is tried again, without a signal
8460    /// and without a pass. One signalled after the stop starts nothing.
8461    #[tokio::test(start_paused = true)]
8462    async fn a_launch_that_did_not_start_is_retried_and_a_stop_ends_it() {
8463        let held = Arc::new(HeldBringUp {
8464            inner: Arc::new(FakeLauncher::new()),
8465            barrier: Arc::new(tokio::sync::Barrier::new(1)),
8466            fail: Some(FailureReason::JitRequestFailed),
8467            waits: Mutex::default(),
8468        });
8469        let director = Arc::new(FixedDirector::default());
8470        let pool = policy(1, "acme/app", 3);
8471        director.directed.lock().unwrap().insert(pool.id, 1);
8472        director.orders.lock().unwrap().insert(pool.id, 1);
8473        let reconciler = directed_reconciler(
8474            5,
8475            held as _,
8476            Arc::new(InProcessAllocationLock::new()),
8477            Arc::clone(&director),
8478        );
8479        let signal = tokio::sync::Notify::new();
8480        let (stop, stopped) = tokio::sync::watch::channel(false);
8481
8482        tokio::join!(serving(&reconciler, &pool, &signal, stopped), async {
8483            signal.notify_one();
8484            tokio::time::sleep(Duration::from_millis(1)).await;
8485            assert_eq!(director.failed.lock().unwrap().len(), 1);
8486            // The fake keeps the failed runner: one more launch needs one more.
8487            director.directed.lock().unwrap().insert(pool.id, 2);
8488            tokio::time::sleep(LAUNCH_RETRY - Duration::from_secs(1)).await;
8489            assert_eq!(director.failed.lock().unwrap().len(), 1, "not yet");
8490            tokio::time::sleep(Duration::from_secs(2)).await;
8491            assert_eq!(director.failed.lock().unwrap().len(), 2, "retried");
8492
8493            stop.send_replace(true);
8494            signal.notify_one();
8495        });
8496        assert_eq!(director.failed.lock().unwrap().len(), 2);
8497    }
8498
8499    /// Coordinated placement: a directed policy starts runners for the
8500    /// coordinator's launches only, through every ceiling, whatever its own
8501    /// reading says; an undirected one keeps its own demand.
8502    #[tokio::test]
8503    async fn a_directed_policy_starts_only_what_it_was_directed_to_within_its_ceilings() {
8504        let demand = Arc::new(FakeDemand::ready(5, &repo("acme/app")));
8505        let launcher = Arc::new(FakeLauncher::new());
8506        let director = Arc::new(FixedDirector::default());
8507        let mut reconciler = Reconciler::new(
8508            host_with(10),
8509            ReconcilerPorts {
8510                demand: Arc::clone(&demand) as Arc<dyn DemandSource>,
8511                launcher: Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
8512                lock: Arc::new(InProcessAllocationLock::new()),
8513                directory: Arc::new(FakeDirectory::default()),
8514                clock: Arc::new(FakeClock::default()),
8515                jitter: Arc::new(NoJitter),
8516                events: Arc::new(NoEvents),
8517            },
8518        )
8519        .with_director(Arc::clone(&director) as Arc<dyn DemandDirector>);
8520        let pool = policy(1, "acme/app", 2);
8521
8522        // Five queued jobs on the poll, but no launch placed here: nothing.
8523        director.directed.lock().unwrap().insert(pool.id, 0);
8524        let report = reconciler.reconcile(std::slice::from_ref(&pool)).await;
8525        assert_eq!(report.started, 0);
8526
8527        // One launch: one runner, reported back to the director.
8528        director.directed.lock().unwrap().insert(pool.id, 1);
8529        let report = reconciler.reconcile(std::slice::from_ref(&pool)).await;
8530        assert_eq!(report.started, 1);
8531        assert_eq!(director.started.lock().unwrap().len(), 1);
8532
8533        // The same launch again is covered by the runner already starting.
8534        let report = reconciler.reconcile(std::slice::from_ref(&pool)).await;
8535        assert_eq!(report.started, 0);
8536
8537        // Three launches against max_capacity 2: the ceiling holds.
8538        director.directed.lock().unwrap().insert(pool.id, 3);
8539        let report = reconciler.reconcile(std::slice::from_ref(&pool)).await;
8540        assert_eq!(report.started, 1);
8541        assert_eq!(launcher.live_count(), 2);
8542
8543        // A launch-only start: every open launch at once, and no poll.
8544        let polls = demand.polls().len();
8545        director.directed.lock().unwrap().insert(pool.id, 0);
8546        let report = reconciler.start_directed(std::slice::from_ref(&pool)).await;
8547        assert_eq!(report.started, 0);
8548        let fresh = Arc::new(FakeLauncher::new());
8549        let starter = Reconciler::new(
8550            host_with(10),
8551            ReconcilerPorts {
8552                demand: Arc::clone(&demand) as Arc<dyn DemandSource>,
8553                launcher: Arc::clone(&fresh) as Arc<dyn RunnerLauncher>,
8554                lock: Arc::new(InProcessAllocationLock::new()),
8555                directory: Arc::new(FakeDirectory::default()),
8556                clock: Arc::new(FakeClock::default()),
8557                jitter: Arc::new(NoJitter),
8558                events: Arc::new(NoEvents),
8559            },
8560        )
8561        .with_director(Arc::clone(&director) as Arc<dyn DemandDirector>);
8562        let wide = policy(2, "acme/app", 6);
8563        director.directed.lock().unwrap().insert(wide.id, 6);
8564        let report = starter.start_directed(std::slice::from_ref(&wide)).await;
8565        assert_eq!(report.started, 6, "six launches, one call");
8566        assert_eq!(demand.polls().len(), polls, "GitHub was not polled");
8567        // An undirected policy starts nothing this way.
8568        assert_eq!(
8569            starter
8570                .start_directed(&[policy(3, "acme/app", 2)])
8571                .await
8572                .started,
8573            0
8574        );
8575
8576        // Its own reading failed: a directed policy still starts its launch.
8577        let failing = Arc::new(FakeLauncher::new());
8578        let mut reconciler = Reconciler::new(
8579            host_with(10),
8580            ReconcilerPorts {
8581                demand: Arc::new(FakeDemand::failing(RefreshState::Offline)),
8582                launcher: Arc::clone(&failing) as Arc<dyn RunnerLauncher>,
8583                lock: Arc::new(InProcessAllocationLock::new()),
8584                directory: Arc::new(FakeDirectory::default()),
8585                clock: Arc::new(FakeClock::default()),
8586                jitter: Arc::new(NoJitter),
8587                events: Arc::new(NoEvents),
8588            },
8589        )
8590        .with_director(Arc::clone(&director) as Arc<dyn DemandDirector>);
8591        director.directed.lock().unwrap().insert(pool.id, 1);
8592        assert_eq!(reconciler.reconcile(&[pool]).await.started, 1);
8593
8594        // Undirected (fallback): its own five jobs, clamped to max 2.
8595        let own = Arc::new(FakeLauncher::new());
8596        let mut reconciler = Reconciler::new(
8597            host_with(10),
8598            ReconcilerPorts {
8599                demand: Arc::new(FakeDemand::ready(5, &repo("acme/app"))),
8600                launcher: Arc::clone(&own) as Arc<dyn RunnerLauncher>,
8601                lock: Arc::new(InProcessAllocationLock::new()),
8602                directory: Arc::new(FakeDirectory::default()),
8603                clock: Arc::new(FakeClock::default()),
8604                jitter: Arc::new(NoJitter),
8605                events: Arc::new(NoEvents),
8606            },
8607        )
8608        .with_director(Arc::new(OwnDemand));
8609        assert_eq!(
8610            reconciler
8611                .reconcile(&[policy(1, "acme/app", 2)])
8612                .await
8613                .started,
8614            2
8615        );
8616    }
8617}