Skip to main content

runner_manager_agent/
reconcile.rs

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