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