Skip to main content

runner_manager_agent/
reconcile.rs

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