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