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