runner_manager_agent/reconcile.rs
1// owner: e1-reconciliation-capacity
2
3//! The loop that turns GitHub demand into a decision to start runners — and
4//! that refuses to start them when it should not.
5//!
6//! Every ceiling in this product is enforced from here, so the module is
7//! organised around the four things that can go wrong silently:
8//!
9//! * [`PollSchedule`] — the budget-aware interval. Demand shares one 5,000
10//! requests/hour ceiling with inventory and workflow counts, so this loop
11//! polls on a bounded interval (default 60 s, hard floor 30 s per target) and
12//! *increases* the delay under a rate-limit signal, never decreases it to
13//! catch up.
14//! * [`RepositoryCache`] — the per-organization repository list, refreshed on
15//! an interval materially slower than the demand poll. Re-listing an
16//! organization at demand-poll frequency is what exhausts the shared budget
17//! the paragraph above exists to protect.
18//! * [`Reconciler::reconcile`] — the allocation pass. It re-reads the attempt
19//! set **under the host-wide allocation lock, once per runtime created**, so
20//! two policies reconciling concurrently cannot both spend the same headroom.
21//! * [`LifecycleEvent`] — what `g2` and the local log sink see. Every field is
22//! an identifier, a count, an enumerated state or a duration; nothing free
23//! text, and nothing that came off the wire.
24//!
25//! # There is no acquisition step, and none may be added
26//!
27//! The scale-set model called `AcquireJobs` to reserve an assignment before
28//! scaling. The REST path has no equivalent (`01-current-architecture.md`, edge
29//! case 6), so demand is **advisory**. Two consequences are load-bearing here
30//! and neither is a defect:
31//!
32//! 1. **A surplus runner is an accepted outcome.** Another host serving the
33//! same labels may take the job first; this host's runner then finds no work
34//! and exits on its idle timeout, having cost one capacity slot and one cold
35//! start. That terminal outcome is
36//! [`AttemptOutcome::ExitedIdleWithoutWork`], is cleaned like any other, and
37//! is counted apart from a failure — see [`ReconcileReport::idle_exits`].
38//! 2. **The same job is still `queued` on the next poll** while its runner
39//! starts. The `- active_owned_runners` term in
40//! [`HostAllocator::allocate`] is what stops that from starting a second
41//! runner, and then a third. This module's only job in that arithmetic is to
42//! hand the allocator the attempt set the host actually holds — which is why
43//! [`RunnerLauncher`] supplies both the attempts and the launch, from one
44//! supply point, for the reason `b1` gives at
45//! [`HostAllocator::from_attempts`].
46//!
47//! `tests::nothing_in_this_module_reserves_or_claims_a_job` is a tripwire on the
48//! obvious shape of a reservation being added back.
49//!
50//! # Demand is measured in JOBS, filtered by this policy's routing labels
51//!
52//! `02-target-architecture.md` writes the formula as *"queued jobs whose
53//! `runs-on` matches this policy's routing labels"*, and that is now exactly
54//! what this module clamps. It was not always: an earlier owner decision priced
55//! the per-run job listing out and left this module clamping a count of
56//! **runs**, unfiltered. `crates/github/src/demand.rs` records that decision,
57//! why it was reversed, and what the reversal costs in requests.
58//!
59//! What the reversal means here is two changes to one line:
60//!
61//! * **A run of eight jobs is now eight units of demand, not one.** Under the
62//! run count a matrix filled one runner per poll while the rest of the matrix
63//! waited, so a host configured for ten concurrent runners served an
64//! eight-job matrix nearly serially. That was the defect that forced the
65//! decision back.
66//! * **A job this host cannot serve is no longer demand.** A repository whose
67//! jobs target `ubuntu-latest`, or another host's `rm-<host>-…` label, used to
68//! drive its policy toward `max_capacity` and start runners that idled until
69//! they timed out. The gateway now returns each queued job's `runs-on`, so
70//! `b1`'s predicate finally has its input.
71//!
72//! **The predicate is still `b1`'s and the input is still `c4`'s.** This module
73//! calls [`runner_manager_domain::policy::RoutingLabels`]'s `tally` and
74//! implements no label comparison of its own;
75//! `tests::the_label_predicate_is_b1s_and_this_module_only_applies_it` scans
76//! this file's own source and fails if a second implementation grows here, which
77//! is the same tripwire `c4` carries one layer down.
78//!
79//! # The filtering happens here rather than in the gateway, on purpose
80//!
81//! One target can be watched by more than one policy, each with its own routing
82//! labels, and [`Reconciler`]'s `poll_targets` deliberately polls a target **once**
83//! for all of them. A gateway that filtered would have to be told whose labels to
84//! filter by, which would make the poll per-policy and multiply its request cost
85//! by the number of policies sharing the target — the budget model prices a
86//! target, not a policy. So the gateway returns the jobs and each policy tallies
87//! them against its own labels.
88//!
89//! # What is still approximate
90//!
91//! The surplus-runner path above is narrowed by this change and not closed. A
92//! `runs-on: ${{ matrix.runner }}` cannot be resolved without evaluating the
93//! workflow, so `b1` reports it as unresolvable: never counted as demand, never
94//! silently dropped, and surfaced through
95//! [`LifecycleEvent::DemandObserved::unresolvable`] so that an operator can see
96//! a workflow this host will never serve sitting in the queue. And demand
97//! remains advisory — another host may still take a job this one started a
98//! runner for — which is what the two ceilings bound.
99//!
100//! # What is testable without a network, a filesystem, or a process
101//!
102//! All of it. [`DemandSource`], [`RunnerLauncher`], [`AllocationLock`],
103//! [`RepositoryDirectory`], [`Jitter`] and [`EventSink`] are ports;
104//! [`GatewayDemand`], [`FileAllocationLock`], [`RandomJitter`] and
105//! [`TracingEvents`] are the production adapters, and every one of them is a
106//! thin shell over a decision made in this file.
107
108use std::collections::{BTreeMap, BTreeSet};
109use std::fmt;
110use std::sync::atomic::{AtomicU64, Ordering};
111use std::sync::{Arc, Mutex};
112use std::time::Duration;
113
114use runner_manager_domain::attempt::{
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// ---------------------------------------------------------------------------
990// The host-wide allocation lock
991// ---------------------------------------------------------------------------
992
993/// The lock is held for as long as this value lives.
994///
995/// Opaque on purpose: what is being held differs between the in-process and the
996/// file-backed implementation, and a caller that could see which one it has
997/// would eventually branch on it.
998pub struct AllocationGuard {
999 _held: Box<dyn std::any::Any + Send + Sync>,
1000}
1001
1002impl AllocationGuard {
1003 /// Wrap whatever the implementation holds. Dropping the guard drops it.
1004 #[must_use]
1005 fn new<T: Send + Sync + 'static>(held: T) -> Self {
1006 Self {
1007 _held: Box::new(held),
1008 }
1009 }
1010}
1011
1012impl fmt::Debug for AllocationGuard {
1013 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1014 f.write_str("AllocationGuard")
1015 }
1016}
1017
1018/// The host-wide allocation lock could not be taken.
1019#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1020#[error("the host-wide allocation lock is held by another allocator; no runtime was created")]
1021pub struct AllocationLockBusy;
1022
1023/// Flow 2.4's *"takes the host-wide allocation lock before creating each local
1024/// runtime"*, as a port.
1025///
1026/// # Why a lock is needed at all, given the allocator already exists
1027///
1028/// [`HostAllocator`] enforces D9 across the policies of **one** pass. It cannot
1029/// enforce anything across two passes running at once, and `f3` runs one
1030/// demand-polling loop per target: without serialisation, two loops read the
1031/// same headroom, each finds it sufficient, and the host ends up with the sum of
1032/// two grants it only ever had room for one of. The lock is what makes the
1033/// read-decide-create sequence atomic, and it is taken once per runtime rather
1034/// than once per pass so that a slow package download in one policy does not
1035/// hold the whole host still.
1036#[async_trait::async_trait]
1037pub trait AllocationLock: fmt::Debug + Send + Sync {
1038 /// Take the lock, waiting briefly for it.
1039 ///
1040 /// # Errors
1041 /// [`AllocationLockBusy`] when it could not be taken. A refused grant is
1042 /// always safe: the next pass re-reads the headroom and tries again.
1043 async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy>;
1044}
1045
1046/// The lock every task inside one agent process contends for.
1047///
1048/// This is the implementation that matters in practice, because the
1049/// single-instance lock (`d1`) already guarantees one agent per host: the
1050/// concurrency the allocation lock actually has to serialise is `f3`'s
1051/// per-target loops inside that one process. A `tokio` mutex rather than a
1052/// `std` one because it is held across the `await` that creates the runtime.
1053#[derive(Debug, Default)]
1054pub struct InProcessAllocationLock {
1055 mutex: Arc<tokio::sync::Mutex<()>>,
1056}
1057
1058impl InProcessAllocationLock {
1059 #[must_use]
1060 pub fn new() -> Self {
1061 Self::default()
1062 }
1063}
1064
1065#[async_trait::async_trait]
1066impl AllocationLock for InProcessAllocationLock {
1067 async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
1068 let mutex = Arc::clone(&self.mutex);
1069 let guard = mutex.lock_owned().await;
1070 Ok(AllocationGuard::new(guard))
1071 }
1072}
1073
1074/// `d1`'s file lock, which is host-wide across processes as well as across
1075/// tasks.
1076///
1077/// Defence in depth behind [`InProcessAllocationLock`], for the configuration
1078/// `d1` documents as the one where two agents can genuinely coexist: the
1079/// platform state directory is per-account, so a service-account daemon and an
1080/// interactive `daemon run` resolve different paths and do not contend for the
1081/// single-instance lock. They do contend here if they share a state directory.
1082///
1083/// [`runner_manager_platform::lock::HostLock::acquire`] blocks the calling
1084/// thread and its own documentation names this caller: *"Async callers must wrap
1085/// it in [`tokio::task::spawn_blocking`]"*. That is what this does, and the
1086/// returned `HostLock` lives inside the guard, because dropping it is the
1087/// release.
1088#[derive(Debug, Clone)]
1089pub struct FileAllocationLock {
1090 paths: Arc<runner_manager_platform::paths::AppPaths>,
1091 wait: Duration,
1092}
1093
1094impl FileAllocationLock {
1095 #[must_use]
1096 pub const fn new(paths: Arc<runner_manager_platform::paths::AppPaths>) -> Self {
1097 Self {
1098 paths,
1099 wait: ALLOCATION_LOCK_WAIT,
1100 }
1101 }
1102
1103 #[must_use]
1104 pub const fn with_wait(mut self, wait: Duration) -> Self {
1105 self.wait = wait;
1106 self
1107 }
1108}
1109
1110#[async_trait::async_trait]
1111impl AllocationLock for FileAllocationLock {
1112 async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
1113 use runner_manager_platform::lock::{HostLock, LockKind};
1114
1115 let paths = Arc::clone(&self.paths);
1116 let wait = self.wait;
1117 let held = tokio::task::spawn_blocking(move || {
1118 HostLock::acquire(&paths, LockKind::Allocation, wait)
1119 })
1120 .await;
1121
1122 match held {
1123 Ok(Ok(lock)) => Ok(AllocationGuard::new(lock)),
1124 // A refused lock and a panicked blocking task are the same outcome
1125 // to this caller: no runtime was created and the next pass will
1126 // re-read the headroom. Neither is allowed to look like a grant.
1127 Ok(Err(_)) | Err(_) => Err(AllocationLockBusy),
1128 }
1129 }
1130}
1131
1132/// Adds the Windows/WSL recovery fence to an ordinary allocation lock.
1133///
1134/// A Windows watchdog writes its drain request before attempting the same
1135/// atomic directory claim. Therefore either this guard owns the directory and
1136/// finishes the one launch already in progress, or Windows owns it and no new
1137/// launch can pass. A malformed configured fence fails closed.
1138#[derive(Debug)]
1139pub struct WslRecoveryAllocationLock {
1140 paths: Arc<runner_manager_platform::paths::AppPaths>,
1141 inner: Arc<dyn AllocationLock>,
1142}
1143
1144impl WslRecoveryAllocationLock {
1145 #[must_use]
1146 pub fn new(
1147 paths: Arc<runner_manager_platform::paths::AppPaths>,
1148 inner: Arc<dyn AllocationLock>,
1149 ) -> Self {
1150 Self { paths, inner }
1151 }
1152}
1153
1154#[async_trait::async_trait]
1155impl AllocationLock for WslRecoveryAllocationLock {
1156 async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
1157 use runner_manager_platform::wsl::fence::{
1158 DrainRequest, FenceClaim, FenceOwnerKind, GuestRecoveryConfig,
1159 };
1160
1161 let paths = Arc::clone(&self.paths);
1162 let claim = tokio::task::spawn_blocking(move || {
1163 let Some(config) = GuestRecoveryConfig::read(&paths).map_err(|_| ())? else {
1164 return Ok(None);
1165 };
1166 let Some(claim) =
1167 FenceClaim::try_claim(&config.shared_root, FenceOwnerKind::GuestLaunch, None)
1168 .map_err(|_| ())?
1169 else {
1170 return Err(());
1171 };
1172 match DrainRequest::read(&config.shared_root) {
1173 Ok(None) => Ok(Some(claim)),
1174 Ok(Some(_)) | Err(_) => Err(()),
1175 }
1176 })
1177 .await
1178 .map_err(|_| AllocationLockBusy)?
1179 .map_err(|()| AllocationLockBusy)?;
1180
1181 let inner = self.inner.acquire().await?;
1182 Ok(AllocationGuard::new((claim, inner)))
1183 }
1184}
1185
1186// ---------------------------------------------------------------------------
1187// Lifecycle events
1188// ---------------------------------------------------------------------------
1189
1190/// Which terminal thing happened, as a closed vocabulary.
1191///
1192/// The distinction `g2` renders: an idle exit is the accepted surplus case and
1193/// **not** a failure, and showing it as one sends an operator hunting a fault
1194/// that does not exist.
1195#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1196pub enum OutcomeKind {
1197 CompletedJob,
1198 IdleExit,
1199 Failed,
1200 Orphaned,
1201}
1202
1203impl OutcomeKind {
1204 #[must_use]
1205 pub const fn of(outcome: &AttemptOutcome) -> Self {
1206 match outcome {
1207 AttemptOutcome::CompletedJob => Self::CompletedJob,
1208 AttemptOutcome::ExitedIdleWithoutWork => Self::IdleExit,
1209 AttemptOutcome::Failed { .. } => Self::Failed,
1210 AttemptOutcome::Orphaned => Self::Orphaned,
1211 }
1212 }
1213
1214 #[must_use]
1215 pub const fn is_failure(&self) -> bool {
1216 matches!(self, Self::Failed | Self::Orphaned)
1217 }
1218
1219 #[must_use]
1220 pub const fn as_str(&self) -> &'static str {
1221 match self {
1222 Self::CompletedJob => "completed_job",
1223 Self::IdleExit => "exited_idle_without_work",
1224 Self::Failed => "failed",
1225 Self::Orphaned => "orphaned",
1226 }
1227 }
1228}
1229
1230impl fmt::Display for OutcomeKind {
1231 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1232 f.write_str(self.as_str())
1233 }
1234}
1235
1236/// A [`FailureReason`]'s variant name, with no detail.
1237///
1238/// [`FailureReason::Other`] carries a `String` that `e3` fills in, and an event
1239/// is not the place for it: `07-security.md`'s log scan runs over everything
1240/// this loop emits, and free text is the one shape that can carry a credential
1241/// past a field allow-list. The operator-facing detail reaches the journal
1242/// through `b2` and the screen through `g2`; what reaches an *event* is the
1243/// variant.
1244#[must_use]
1245pub const fn failure_reason_kind(reason: &FailureReason) -> &'static str {
1246 match reason {
1247 FailureReason::JitRequestFailed => "jit_request_failed",
1248 FailureReason::JitExpired => "jit_expired",
1249 FailureReason::RunnerPackageUnverified => "runner_package_unverified",
1250 FailureReason::RunnerVersionRejected => "runner_version_rejected",
1251 FailureReason::ProcessStartFailed => "process_start_failed",
1252 FailureReason::ProcessExitedUnexpectedly => "process_exited_unexpectedly",
1253 FailureReason::RegistrationTimedOut => "registration_timed_out",
1254 FailureReason::TerminatedAfterRegistrationTimeout => {
1255 "terminated_after_registration_timeout"
1256 }
1257 FailureReason::IsolationProvider(category) => match category {
1258 IsolationProviderFailure::Unsupported => "isolation_provider_unsupported",
1259 IsolationProviderFailure::NotInstalled => "isolation_provider_not_installed",
1260 IsolationProviderFailure::PermissionDenied => "isolation_provider_permission_denied",
1261 IsolationProviderFailure::ImageUnavailableOrIncompatible => {
1262 "isolation_image_unavailable"
1263 }
1264 IsolationProviderFailure::DiskQuotaUnavailable => "isolation_disk_quota_unavailable",
1265 IsolationProviderFailure::Degraded => "isolation_provider_degraded",
1266 IsolationProviderFailure::RuntimeOperationFailed => {
1267 "isolation_runtime_operation_failed"
1268 }
1269 IsolationProviderFailure::OwnershipMismatch => "isolation_ownership_mismatch",
1270 IsolationProviderFailure::UnsafeRuntimePath => "isolation_unsafe_runtime_path",
1271 IsolationProviderFailure::JitHandoffRejected => "isolation_jit_handoff_rejected",
1272 },
1273 FailureReason::WorkspaceCleanupDeferred => "ephemeral_workspace_could_not_be_removed",
1274 FailureReason::Other(_) => "other",
1275 }
1276}
1277
1278/// Fixed, credential-free reasons a queued job cannot route to one profile.
1279#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
1280pub enum RoutingRefusal {
1281 OverlappingProfiles,
1282}
1283
1284impl RoutingRefusal {
1285 #[must_use]
1286 pub const fn as_str(self) -> &'static str {
1287 match self {
1288 Self::OverlappingProfiles => "overlapping_profile_matches",
1289 }
1290 }
1291}
1292
1293impl fmt::Display for RoutingRefusal {
1294 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1295 f.write_str(match self {
1296 Self::OverlappingProfiles => {
1297 "repair overlapping profile labels or require a unique profile selector"
1298 }
1299 })
1300 }
1301}
1302
1303/// What `g2`'s activity view and the local log sink see.
1304///
1305/// **Every field is an identifier, a count, a duration, or a `&'static str`
1306/// drawn from a closed set.** There is no `String` anywhere in this enum, which
1307/// is what makes "no emitted event contains a token, a JIT blob, or a credential
1308/// header" a property of the type rather than a discipline each call site has to
1309/// keep. `tests::no_emitted_event_can_carry_a_credential` renders every variant
1310/// through `d1`'s scrubber and asserts nothing changes, with a positive control
1311/// so the assertion cannot pass vacuously.
1312#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1313pub enum LifecycleEvent {
1314 /// A demand poll answered for one target.
1315 DemandObserved {
1316 policy: PolicyId,
1317 /// Queued jobs this policy's routing labels match. The number clamped.
1318 demand: u32,
1319 /// Queued jobs whose required labels this policy does not carry.
1320 ///
1321 /// Never demand. Reported because the difference between this and
1322 /// `demand` is the whole value of the label filtering, and an operator
1323 /// wondering why a busy repository started no runners is owed it.
1324 not_matched: u32,
1325 /// Queued jobs whose `runs-on` could not be resolved statically.
1326 ///
1327 /// `b1` requires these be "reported as unresolvable rather than silently
1328 /// counted or silently dropped": counting one would start a runner for a
1329 /// job that may not be ours, and dropping it would hide a workflow this
1330 /// host can never serve. A count rather than the reasons themselves
1331 /// because this type is `Copy`, and `c4` logs the reasons where it
1332 /// builds them.
1333 unresolvable: u32,
1334 /// `false` when the count is a floor rather than a total.
1335 complete: bool,
1336 },
1337 /// A queued job cannot be assigned unambiguously to this target's profiles.
1338 RoutingRefused {
1339 policy: PolicyId,
1340 reason: RoutingRefusal,
1341 count: u32,
1342 },
1343 /// A target could not be polled, so its policies start nothing this pass.
1344 TargetUnreadable {
1345 policy: PolicyId,
1346 reason: &'static str,
1347 },
1348 /// One policy's share of the pass.
1349 Allocated {
1350 policy: PolicyId,
1351 demand: u32,
1352 desired: u16,
1353 active_owned: u16,
1354 headroom: u16,
1355 to_start: u16,
1356 limiting: LimitingFactor,
1357 },
1358 /// A monitor-only policy was skipped entirely, before any demand request
1359 /// was issued for it (D19).
1360 MonitorOnlySkipped { policy: PolicyId },
1361 /// One runtime was created and one runner started.
1362 RunnerStarted {
1363 policy: PolicyId,
1364 attempt: AttemptId,
1365 },
1366 /// One runner could not be started.
1367 RunnerStartFailed {
1368 policy: PolicyId,
1369 reason: &'static str,
1370 },
1371 /// The allocation lock was not free, so `count` runners this policy was
1372 /// granted were not created this pass.
1373 AllocationDeferred { policy: PolicyId, count: u16 },
1374 /// The host's attempt set could not be read at all.
1375 ///
1376 /// Distinct from an empty set on purpose, and the whole reason
1377 /// [`RunnerLauncher::attempts`] is fallible: the two produce the same
1378 /// *number* and demand opposite actions.
1379 AttemptsUnreadable { reason: &'static str },
1380 /// A terminal attempt's runtime was removed.
1381 AttemptCleaned {
1382 policy: PolicyId,
1383 attempt: AttemptId,
1384 outcome: OutcomeKind,
1385 },
1386 /// A terminal attempt's runtime could not be removed. It will be retried on
1387 /// the next pass, and this is what keeps that retry from being silent.
1388 AttemptCleanFailed {
1389 policy: PolicyId,
1390 attempt: AttemptId,
1391 reason: &'static str,
1392 },
1393 /// Scale-down declined to remove a runner that is executing a job.
1394 ScaleDownRefused {
1395 policy: PolicyId,
1396 attempt: AttemptId,
1397 },
1398 /// When the next poll is, and why then.
1399 PollScheduled { retry_in_ms: u64, pace: PollPace },
1400}
1401
1402impl LifecycleEvent {
1403 /// A fixed name, for the `event` field `d1`'s sink allows verbatim.
1404 #[must_use]
1405 pub const fn name(&self) -> &'static str {
1406 match self {
1407 Self::DemandObserved { .. } => "demand_observed",
1408 Self::RoutingRefused { .. } => "routing_refused",
1409 Self::TargetUnreadable { .. } => "target_unreadable",
1410 Self::Allocated { .. } => "allocated",
1411 Self::MonitorOnlySkipped { .. } => "monitor_only_skipped",
1412 Self::RunnerStarted { .. } => "runner_started",
1413 Self::RunnerStartFailed { .. } => "runner_start_failed",
1414 Self::AllocationDeferred { .. } => "allocation_deferred",
1415 Self::AttemptsUnreadable { .. } => "attempts_unreadable",
1416 Self::AttemptCleaned { .. } => "attempt_cleaned",
1417 Self::AttemptCleanFailed { .. } => "attempt_clean_failed",
1418 Self::ScaleDownRefused { .. } => "scale_down_refused",
1419 Self::PollScheduled { .. } => "poll_scheduled",
1420 }
1421 }
1422
1423 /// Which policy this event is about.
1424 #[must_use]
1425 pub const fn policy(&self) -> Option<PolicyId> {
1426 match self {
1427 Self::DemandObserved { policy, .. }
1428 | Self::RoutingRefused { policy, .. }
1429 | Self::TargetUnreadable { policy, .. }
1430 | Self::Allocated { policy, .. }
1431 | Self::MonitorOnlySkipped { policy }
1432 | Self::RunnerStarted { policy, .. }
1433 | Self::RunnerStartFailed { policy, .. }
1434 | Self::AllocationDeferred { policy, .. }
1435 | Self::AttemptCleaned { policy, .. }
1436 | Self::AttemptCleanFailed { policy, .. }
1437 | Self::ScaleDownRefused { policy, .. } => Some(*policy),
1438 Self::PollScheduled { .. } | Self::AttemptsUnreadable { .. } => None,
1439 }
1440 }
1441}
1442
1443impl fmt::Display for LifecycleEvent {
1444 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1445 match self {
1446 Self::DemandObserved {
1447 policy,
1448 demand,
1449 not_matched,
1450 unresolvable,
1451 complete,
1452 } => write!(
1453 f,
1454 "policy {policy}: {demand} queued jobs for this host{}{}{}",
1455 if *not_matched == 0 {
1456 String::new()
1457 } else {
1458 format!(", {not_matched} for other labels")
1459 },
1460 if *unresolvable == 0 {
1461 String::new()
1462 } else {
1463 format!(", {unresolvable} with an unresolvable `runs-on`")
1464 },
1465 if *complete {
1466 ""
1467 } else {
1468 " (a floor, not a total)"
1469 }
1470 ),
1471 Self::TargetUnreadable { policy, reason } => {
1472 write!(f, "policy {policy}: target unreadable ({reason})")
1473 }
1474 Self::RoutingRefused {
1475 policy,
1476 reason,
1477 count,
1478 } => write!(f, "policy {policy}: {count} queued jobs blocked: {reason}"),
1479 Self::Allocated {
1480 policy,
1481 demand,
1482 desired,
1483 active_owned,
1484 headroom,
1485 to_start,
1486 limiting,
1487 } => write!(
1488 f,
1489 "policy {policy}: demand {demand}, desired {desired}, {active_owned} in \
1490 flight, {headroom} free on this host, starting {to_start} ({limiting})"
1491 ),
1492 Self::MonitorOnlySkipped { policy } => {
1493 write!(f, "policy {policy}: monitor-only, skipped")
1494 }
1495 Self::RunnerStarted { policy, attempt } => {
1496 write!(f, "policy {policy}: started attempt {attempt}")
1497 }
1498 Self::RunnerStartFailed { policy, reason } => {
1499 write!(f, "policy {policy}: could not start a runner ({reason})")
1500 }
1501 Self::AllocationDeferred { policy, count } => write!(
1502 f,
1503 "policy {policy}: the allocation lock was held; {count} granted runners \
1504 were not created"
1505 ),
1506 Self::AttemptsUnreadable { reason } => write!(
1507 f,
1508 "the host's attempt set could not be read ({reason}); nothing was started, \
1509 and this is not the same as the host being idle"
1510 ),
1511 Self::AttemptCleaned {
1512 policy,
1513 attempt,
1514 outcome,
1515 } => write!(f, "policy {policy}: cleaned attempt {attempt} ({outcome})"),
1516 Self::AttemptCleanFailed {
1517 policy,
1518 attempt,
1519 reason,
1520 } => write!(
1521 f,
1522 "policy {policy}: attempt {attempt} could not be cleaned ({reason}); it \
1523 will be retried"
1524 ),
1525 Self::ScaleDownRefused { policy, attempt } => write!(
1526 f,
1527 "policy {policy}: attempt {attempt} is executing a job and was not removed"
1528 ),
1529 Self::PollScheduled { retry_in_ms, pace } => {
1530 write!(f, "next poll in {retry_in_ms}ms ({pace})")
1531 }
1532 }
1533 }
1534}
1535
1536/// Where lifecycle events go.
1537pub trait EventSink: fmt::Debug + Send + Sync {
1538 fn emit(&self, event: LifecycleEvent);
1539}
1540
1541/// Discards everything. For callers that only want the report.
1542#[derive(Debug, Clone, Copy, Default)]
1543pub struct NoEvents;
1544
1545impl EventSink for NoEvents {
1546 fn emit(&self, _event: LifecycleEvent) {}
1547}
1548
1549/// The local log sink, through `d1`'s redacting layer.
1550///
1551/// Every field name below is on
1552/// [`runner_manager_platform::logging::ALLOWED_FIELDS`]; anything else would be
1553/// replaced with `[redacted]` and the line would lose its meaning rather than
1554/// its safety. `tests::every_field_name_this_sink_emits_is_one_d1_allows` keeps
1555/// that true.
1556#[derive(Debug, Clone, Copy, Default)]
1557pub struct TracingEvents;
1558
1559impl EventSink for TracingEvents {
1560 fn emit(&self, event: LifecycleEvent) {
1561 let name = event.name();
1562 match event {
1563 LifecycleEvent::DemandObserved {
1564 policy,
1565 demand,
1566 not_matched,
1567 unresolvable,
1568 complete,
1569 } => {
1570 tracing::info!(
1571 event = name,
1572 policy_id = %policy,
1573 demand,
1574 not_matched,
1575 unresolvable,
1576 count = u64::from(complete),
1577 );
1578 // There is deliberately no `warn!` here for the "demand is zero
1579 // but jobs were not matched" shape, though it is the one this
1580 // change introduced: before demand was filtered, a repository
1581 // with work in it always produced some, and now a policy whose
1582 // labels do not cover its jobs produces none.
1583 //
1584 // The reason is that the shape is indistinguishable from a
1585 // healthy one. A repository served by a Windows host and a macOS
1586 // host has the other host's jobs queued in it constantly, so
1587 // each agent would warn on every poll about work that is being
1588 // served correctly by the other machine. Telling the two apart
1589 // needs to know whether this policy has *ever* matched anything,
1590 // which is state across polls that this loop does not keep.
1591 //
1592 // What an operator gets instead is the `not_matched` count, on
1593 // this event and in its `Display`, which `g2` renders. "0 queued
1594 // jobs for this host, 5 for other labels" is the diagnosis; a
1595 // warning that fired on every healthy minute would be the kind
1596 // nobody reads.
1597 }
1598 LifecycleEvent::TargetUnreadable { policy, reason } => {
1599 tracing::warn!(event = name, policy_id = %policy, reason);
1600 }
1601 LifecycleEvent::RoutingRefused {
1602 policy,
1603 reason,
1604 count,
1605 } => tracing::warn!(
1606 event = name,
1607 policy_id = %policy,
1608 reason = reason.as_str(),
1609 count,
1610 "{reason}"
1611 ),
1612 LifecycleEvent::Allocated {
1613 policy,
1614 demand,
1615 desired,
1616 active_owned,
1617 headroom,
1618 to_start,
1619 limiting,
1620 } => tracing::info!(
1621 event = name,
1622 policy_id = %policy,
1623 demand,
1624 desired,
1625 capacity = active_owned,
1626 headroom,
1627 count = to_start,
1628 reason = %limiting,
1629 ),
1630 LifecycleEvent::MonitorOnlySkipped { policy } => {
1631 tracing::debug!(event = name, policy_id = %policy, mode = "monitor_only");
1632 }
1633 LifecycleEvent::RunnerStarted { policy, attempt } => {
1634 tracing::info!(event = name, policy_id = %policy, attempt_id = %attempt);
1635 }
1636 LifecycleEvent::RunnerStartFailed { policy, reason } => {
1637 tracing::warn!(event = name, policy_id = %policy, reason);
1638 }
1639 LifecycleEvent::AllocationDeferred { policy, count } => {
1640 // Contention is normally brief, but a configured WSL recovery
1641 // fence whose shared root is unwritable reaches the same
1642 // fail-closed result forever. Demand was already observed and
1643 // capacity was already granted, so hiding this at DEBUG makes
1644 // a host claim healthy while it starts nothing.
1645 tracing::warn!(
1646 event = name,
1647 policy_id = %policy,
1648 reason = "allocation_lock_unavailable",
1649 count
1650 );
1651 }
1652 LifecycleEvent::AttemptsUnreadable { reason } => {
1653 tracing::warn!(event = name, reason);
1654 }
1655 LifecycleEvent::AttemptCleaned {
1656 policy,
1657 attempt,
1658 outcome,
1659 } => tracing::info!(
1660 event = name,
1661 policy_id = %policy,
1662 attempt_id = %attempt,
1663 outcome = outcome.as_str(),
1664 ),
1665 LifecycleEvent::AttemptCleanFailed {
1666 policy,
1667 attempt,
1668 reason,
1669 } => tracing::warn!(
1670 event = name,
1671 policy_id = %policy,
1672 attempt_id = %attempt,
1673 reason,
1674 ),
1675 LifecycleEvent::ScaleDownRefused { policy, attempt } => tracing::info!(
1676 event = name,
1677 policy_id = %policy,
1678 attempt_id = %attempt,
1679 attempt_state = "busy",
1680 ),
1681 LifecycleEvent::PollScheduled { retry_in_ms, pace } => {
1682 tracing::info!(event = name, retry_in_ms, state = pace.as_str());
1683 }
1684 }
1685 }
1686}
1687
1688/// Keeps every event, in order.
1689///
1690/// `g2`'s activity view is a reader of this, and so is every test below.
1691#[derive(Debug, Default)]
1692pub struct EventLog {
1693 events: Mutex<Vec<LifecycleEvent>>,
1694}
1695
1696impl EventLog {
1697 #[must_use]
1698 pub fn new() -> Self {
1699 Self::default()
1700 }
1701
1702 #[must_use]
1703 pub fn events(&self) -> Vec<LifecycleEvent> {
1704 self.events.lock().map(|e| e.clone()).unwrap_or_default()
1705 }
1706
1707 /// How many events of one name were emitted.
1708 #[must_use]
1709 pub fn count_of(&self, name: &str) -> usize {
1710 self.events()
1711 .iter()
1712 .filter(|event| event.name() == name)
1713 .count()
1714 }
1715}
1716
1717impl EventSink for EventLog {
1718 fn emit(&self, event: LifecycleEvent) {
1719 if let Ok(mut events) = self.events.lock() {
1720 events.push(event);
1721 }
1722 }
1723}
1724
1725/// Both sinks at once: the log sink for the operator's file, the buffer for
1726/// `g2`'s screen.
1727#[derive(Debug)]
1728pub struct TeeEvents(pub Arc<dyn EventSink>, pub Arc<dyn EventSink>);
1729
1730impl EventSink for TeeEvents {
1731 fn emit(&self, event: LifecycleEvent) {
1732 self.0.emit(event);
1733 self.1.emit(event);
1734 }
1735}
1736
1737// ---------------------------------------------------------------------------
1738// The reconciler
1739// ---------------------------------------------------------------------------
1740
1741/// Everything one reconciler needs, written down at the call site.
1742///
1743/// A struct rather than seven positional arguments, for the reason `b1` gives at
1744/// `PersistedAttempt`: several of these are `Arc<dyn …>` and transposing two of
1745/// them type-checks. Construct it with a struct literal so every port is named.
1746pub struct ReconcilerPorts {
1747 pub demand: Arc<dyn DemandSource>,
1748 pub launcher: Arc<dyn RunnerLauncher>,
1749 pub lock: Arc<dyn AllocationLock>,
1750 pub directory: Arc<dyn RepositoryDirectory>,
1751 pub clock: Arc<dyn Clock>,
1752 pub jitter: Arc<dyn Jitter>,
1753 pub events: Arc<dyn EventSink>,
1754}
1755
1756impl fmt::Debug for ReconcilerPorts {
1757 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1758 f.debug_struct("ReconcilerPorts").finish_non_exhaustive()
1759 }
1760}
1761
1762/// What one reconciliation pass did.
1763///
1764/// `started` and the allocations are reported separately on purpose: an
1765/// allocation is what the pass *decided* under the lock, and `started` is what
1766/// actually came up. They differ when a launch fails or when the lock was held,
1767/// and collapsing them would hide both.
1768#[derive(Debug, Clone, Default)]
1769pub struct ReconcileReport {
1770 /// One entry per policy that got as far as being allocated for.
1771 pub allocations: Vec<Allocation>,
1772 /// Policies skipped because they are monitor-only (D19).
1773 pub monitor_only: Vec<PolicyId>,
1774 /// Policies whose target could not be polled this pass.
1775 pub unreadable: Vec<PolicyId>,
1776 /// Policies whose target GitHub actually answered for this pass.
1777 ///
1778 /// The counterpart to [`Self::unreadable`], and the only honest evidence
1779 /// that this host reached GitHub at all. [`Self::allocations`] is not: a
1780 /// policy this host does not own is allocated for with no demand and
1781 /// without any target being polled, so a pass where every poll failed can
1782 /// still end with allocations in it.
1783 pub targets_read: u16,
1784 /// Runners actually started.
1785 pub started: u16,
1786 /// Pre-acceptance attempts routed back through this pass's ordinary
1787 /// demand/capacity decision.
1788 pub replacement_intents: u16,
1789 /// Terminal attempts whose runtime was removed.
1790 pub cleaned: u16,
1791 /// Of those, the surplus case: registered, got no job, exited on its idle
1792 /// timeout. **Not** a failure.
1793 pub idle_exits: u16,
1794 /// Of those, the ones an operator should look at.
1795 pub failures: u16,
1796 /// Runners this pass was granted but did not start because the allocation
1797 /// lock was held.
1798 ///
1799 /// **Grants, not policies.** It used to be incremented once per
1800 /// `start_runners` call that met a held lock, so a policy that launched two
1801 /// of five and then lost the lock reported `1` while three runners went
1802 /// unstarted -- a number that agreed with neither its own name nor its
1803 /// documentation.
1804 pub deferred: u16,
1805 /// Times the host's attempt set could not be read this pass.
1806 ///
1807 /// Non-zero means the pass decided less than it looks like it decided: a
1808 /// policy whose attempt set was unreadable started nothing and is *not* in
1809 /// [`Self::allocations`], because there was no set to compute an allocation
1810 /// from. It is not the same as the host being idle, which is the whole
1811 /// reason [`RunnerLauncher::attempts`] is fallible.
1812 ///
1813 /// **A count, where [`Self::unreadable`] is a `Vec<PolicyId>`, and that
1814 /// asymmetry is deliberate.** An unreadable *target* is a fact about one
1815 /// policy's GitHub target; an unreadable *attempt set* is a fact about this
1816 /// host's journal, which no policy owns — two of the three paths that reach
1817 /// it (`clean_terminal_attempts` and `scale_down`) have no policy in hand at
1818 /// all. Naming policies here would mean either inventing an owner for a
1819 /// host-wide failure or reporting a partial list, and both read as more
1820 /// precision than there is. The pass is distinguishable from an idle one,
1821 /// which is what the field exists for; the per-policy attribution is not
1822 /// available, and is recorded as missing rather than faked.
1823 pub attempts_unreadable: u16,
1824 /// Terminal attempts whose runtime could not be removed. Retried next pass.
1825 pub clean_failures: u16,
1826 /// The most severe failure across the targets polled, when there was one.
1827 pub failure: Option<RefreshState>,
1828 /// What to display while GitHub is unreachable, including how long the
1829 /// outage has run and therefore whether queued work has already been lost.
1830 pub offline: Option<OfflineState>,
1831 /// When to poll next, and why then.
1832 pub next_poll: NextPoll,
1833 /// Demand requests this pass projected against the shared hourly ceiling.
1834 pub demand_requests: u32,
1835}
1836
1837impl ReconcileReport {
1838 /// Whether this pass actually reached GitHub, which is the only thing that
1839 /// entitles it to write a `last GitHub contact`.
1840 ///
1841 /// # Positive evidence, because the absence of a failure is not evidence
1842 ///
1843 /// The record used to be written whenever [`Self::failure`] was `None`, on
1844 /// the belief that an unauthorized target lands in [`Self::unreadable`]
1845 /// rather than in `failure`. **That belief is wrong.** `unreadable` is
1846 /// pushed only from the `PollOutcome::Failed` arm, `failure` is the maximum
1847 /// over every `Failed` reading, and `RefreshState::Unauthorized` scores 2 —
1848 /// so a non-empty `unreadable` always implies `failure.is_some()`, and
1849 /// guarding on both would have changed nothing at all.
1850 ///
1851 /// The path that really writes a contact record without touching GitHub is
1852 /// a pass that polls **nothing**: every policy draining, owned by another
1853 /// host, or monitor-only. `pollable` is then empty, no reading exists, no
1854 /// failure is computed, and the old guard passed. That is how
1855 /// `service status` can answer `healthy` on a host doing nothing at all.
1856 ///
1857 /// So this asks for evidence rather than for the absence of a complaint. A
1858 /// pass with nothing to ask reaches nobody and records nothing, which is
1859 /// what `never` in `service status` is for.
1860 ///
1861 /// Conservative on purpose: `repositories.scope_for` is a real request that
1862 /// can succeed before a demand poll fails, and it is not counted. Contact
1863 /// that cannot be proven is not claimed.
1864 #[must_use]
1865 pub const fn reached_github(&self) -> bool {
1866 self.targets_read > 0
1867 }
1868}
1869
1870impl Default for NextPoll {
1871 fn default() -> Self {
1872 Self {
1873 delay: PollSchedule::floor(),
1874 pace: PollPace::Nominal,
1875 }
1876 }
1877}
1878
1879impl ReconcileReport {
1880 /// Whether GitHub was unreachable this pass.
1881 #[must_use]
1882 pub fn is_offline(&self) -> bool {
1883 matches!(self.failure, Some(RefreshState::Offline))
1884 }
1885
1886 /// The offline state to display, when this pass was one.
1887 #[must_use]
1888 pub const fn offline_state(&self) -> Option<&OfflineState> {
1889 self.offline.as_ref()
1890 }
1891
1892 /// Attempts this pass created. The idle-host assertion reads this.
1893 #[must_use]
1894 pub const fn starts_nothing(&self) -> bool {
1895 self.started == 0
1896 }
1897}
1898
1899/// What one scale-down request did.
1900#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1901pub struct ScaleDownReport {
1902 /// Terminal attempts whose runtime was removed.
1903 pub removed: u16,
1904 /// Attempts executing a job. **Removed nothing, left `busy`.**
1905 pub refused_busy: u16,
1906 /// Terminal attempts whose runtime could not be removed.
1907 pub clean_failures: u16,
1908 /// Live attempts that are not yet busy. Also removed nothing: capacity is
1909 /// reclaimed only when an attempt reaches a terminal state.
1910 pub retained: u16,
1911 /// The host's attempt set could not be read, so **every other field here is
1912 /// meaningless** rather than zero.
1913 ///
1914 /// This is the same distinction [`ReconcileReport::attempts_unreadable`]
1915 /// draws, and it is here for the same reason: a default
1916 /// [`ScaleDownReport`] and a scale-down that could not see the machine are
1917 /// both all-zeros, and they mean opposite things — "there was nothing to
1918 /// reclaim" against "we do not know what there was". Check
1919 /// [`Self::is_conclusive`] before reading a zero as an answer.
1920 pub attempts_unreadable: bool,
1921}
1922
1923impl ScaleDownReport {
1924 /// Whether the counts here describe the machine at all.
1925 ///
1926 /// `false` means the attempt set could not be read, so every zero is
1927 /// "unknown" rather than "none".
1928 #[must_use]
1929 pub const fn is_conclusive(&self) -> bool {
1930 !self.attempts_unreadable
1931 }
1932}
1933
1934/// How long a failed cleanup of one attempt waits before it is tried again,
1935/// doubling per consecutive failure up to [`CLEAN_RETRY_MAXIMUM`].
1936///
1937/// A runtime that cannot be removed used to be retried on every pass, about
1938/// every six seconds, and each failure logs a warning: one stuck Windows
1939/// workspace wrote some 15,000 of them a day. The retry is still real; it is
1940/// just no longer a log flood.
1941const CLEAN_RETRY_INITIAL: Duration = Duration::from_secs(30);
1942
1943/// The longest a failing cleanup waits between tries.
1944const CLEAN_RETRY_MAXIMUM: Duration = Duration::from_secs(30 * 60);
1945
1946/// One attempt's consecutive cleanup failures, and when it may be tried again.
1947#[derive(Debug, Clone, Copy)]
1948struct CleanRetry {
1949 failures: u32,
1950 not_before: Timestamp,
1951}
1952
1953/// Per-attempt cleanup backoff. One each in the [`Reconciler`]'s sweep of
1954/// concluded attempts and in the lifecycle launcher's own supervision pass,
1955/// the two places a failed cleanup is retried.
1956#[derive(Debug, Default)]
1957pub(crate) struct CleanBackoff {
1958 retries: BTreeMap<AttemptId, CleanRetry>,
1959}
1960
1961impl CleanBackoff {
1962 /// The doubling-with-a-ceiling schedule; the attempt bound is unused.
1963 const SCHEDULE: crate::lifecycle::RetryPolicy =
1964 crate::lifecycle::RetryPolicy::bounded(u32::MAX, CLEAN_RETRY_INITIAL, CLEAN_RETRY_MAXIMUM);
1965
1966 /// Whether `attempt`'s cleanup should be tried at `now`.
1967 pub(crate) fn is_due(&self, attempt: AttemptId, now: Timestamp) -> bool {
1968 self.retries
1969 .get(&attempt)
1970 .is_none_or(|retry| now >= retry.not_before)
1971 }
1972
1973 /// Records a failed cleanup and schedules the next try.
1974 pub(crate) fn failed(&mut self, attempt: AttemptId, now: Timestamp) {
1975 let failures = self
1976 .retries
1977 .get(&attempt)
1978 .map_or(1, |retry| retry.failures.saturating_add(1));
1979 let delay = Self::SCHEDULE.delay(failures);
1980 self.retries.insert(
1981 attempt,
1982 CleanRetry {
1983 failures,
1984 // At most `CLEAN_RETRY_MAXIMUM`, so always representable.
1985 not_before: now + chrono::Duration::from_std(delay).unwrap_or_default(),
1986 },
1987 );
1988 }
1989
1990 /// Forgets `attempt`: it was cleaned.
1991 pub(crate) fn succeeded(&mut self, attempt: AttemptId) {
1992 self.retries.remove(&attempt);
1993 }
1994
1995 /// Forgets every attempt not in `present`, so the map cannot outgrow the
1996 /// journal.
1997 fn retain(&mut self, present: &BTreeSet<AttemptId>) {
1998 self.retries.retain(|attempt, _| present.contains(attempt));
1999 }
2000}
2001
2002/// The reconciliation loop.
2003///
2004/// One per target, as `f3` runs them; they share a [`RunnerLauncher`] and an
2005/// [`AllocationLock`], which is what keeps the host ceiling true across all of
2006/// them.
2007#[derive(Debug)]
2008pub struct Reconciler {
2009 host: Host,
2010 demand: Arc<dyn DemandSource>,
2011 launcher: Arc<dyn RunnerLauncher>,
2012 lock: Arc<dyn AllocationLock>,
2013 repositories: RepositoryCache,
2014 clock: Arc<dyn Clock>,
2015 jitter: Arc<dyn Jitter>,
2016 events: Arc<dyn EventSink>,
2017 schedule: PollSchedule,
2018 clean_backoff: CleanBackoff,
2019}
2020
2021impl Reconciler {
2022 /// Build a reconciler polling at the host's configured interval.
2023 #[must_use]
2024 pub fn new(host: Host, ports: ReconcilerPorts) -> Self {
2025 let interval = host.refresh_interval;
2026 let repositories = RepositoryCache::new(
2027 Arc::clone(&ports.directory),
2028 Arc::clone(&ports.clock),
2029 interval,
2030 );
2031 Self {
2032 host,
2033 demand: ports.demand,
2034 launcher: ports.launcher,
2035 lock: ports.lock,
2036 repositories,
2037 clock: ports.clock,
2038 jitter: ports.jitter,
2039 events: ports.events,
2040 schedule: PollSchedule::new(interval),
2041 clean_backoff: CleanBackoff::default(),
2042 }
2043 }
2044
2045 #[must_use]
2046 pub const fn host(&self) -> &Host {
2047 &self.host
2048 }
2049
2050 #[must_use]
2051 pub const fn schedule(&self) -> &PollSchedule {
2052 &self.schedule
2053 }
2054
2055 /// The repository-list cache, so `f1` can report what it has spent.
2056 #[must_use]
2057 pub const fn repositories(&self) -> &RepositoryCache {
2058 &self.repositories
2059 }
2060
2061 /// One reconciliation pass over `policies`.
2062 ///
2063 /// The order of operations is `03-control-flows.md` flow 2, and the two
2064 /// steps most worth naming are the ones that are silent when they are wrong:
2065 ///
2066 /// * **Monitor-only policies are removed before the demand poll**, not
2067 /// after. D19 says such a policy "is skipped entirely by reconciliation",
2068 /// and a poll issued on its behalf would spend requests from the shared
2069 /// ceiling for a policy that can never act on the answer. This is asserted
2070 /// on [`ScalePolicy::owns_runners`] rather than deduced from
2071 /// `max_capacity` being absent.
2072 /// * **The attempt set is re-read under the lock, once per runtime.** See
2073 /// [`RunnerLauncher`] for why it comes from there and nowhere else.
2074 pub async fn reconcile(&mut self, policies: &[ScalePolicy]) -> ReconcileReport {
2075 let mut report = ReconcileReport::default();
2076 // Everything this pass has created, carried across policies so that the
2077 // host-wide total cannot be computed from a set that is missing it. See
2078 // `RunnerLauncher::launch`.
2079 let mut launched: Vec<RunnerAttempt> = Vec::new();
2080
2081 // --- Flow 2.1-2.2: who is even asking, and what did GitHub say -------
2082 let mut pollable: Vec<&ScalePolicy> = Vec::new();
2083 let mut supervision_failed = BTreeSet::new();
2084 for policy in policies {
2085 if !policy.owns_runners() {
2086 report.monitor_only.push(policy.id);
2087 self.events
2088 .emit(LifecycleEvent::MonitorOnlySkipped { policy: policy.id });
2089 continue;
2090 }
2091 if !policy.is_owned_by(self.host.id) {
2092 // Ownership rule 2 and precedence rule 4. The allocator reports
2093 // both by name below; polling on their behalf would spend
2094 // requests for an answer that cannot be acted on.
2095 continue;
2096 }
2097 match self.launcher.supervise(policy).await {
2098 Ok(intents) => {
2099 report.replacement_intents = report
2100 .replacement_intents
2101 .saturating_add(u16::try_from(intents.len()).unwrap_or(u16::MAX));
2102 }
2103 Err(failure) => {
2104 supervision_failed.insert(policy.id);
2105 self.report_unreadable_attempts(&mut report, &failure);
2106 continue;
2107 }
2108 }
2109 if !policy.may_start_runners() {
2110 continue;
2111 }
2112 pollable.push(policy);
2113 }
2114
2115 let readings = self.poll_targets(&pollable, &mut report).await;
2116 report.targets_read = u16::try_from(
2117 readings
2118 .values()
2119 .filter(|outcome| matches!(outcome, PollOutcome::Ready(_)))
2120 .count(),
2121 )
2122 .unwrap_or(u16::MAX);
2123 let mut routed = BTreeMap::new();
2124 for (target, outcome) in &readings {
2125 let PollOutcome::Ready(reading) = outcome else {
2126 continue;
2127 };
2128 let profiles: Vec<&ScalePolicy> = pollable
2129 .iter()
2130 .copied()
2131 .filter(|policy| &policy.target == target)
2132 .collect();
2133 let (tallies, refusals) = route_demand(policies, &profiles, reading);
2134 if let Some(first) = profiles.first() {
2135 for (reason, count) in refusals {
2136 self.events.emit(LifecycleEvent::RoutingRefused {
2137 policy: first.id,
2138 reason,
2139 count,
2140 });
2141 }
2142 }
2143 routed.insert(target.clone(), tallies);
2144 }
2145
2146 // --- Flow 2.8: terminal attempts, whatever else this pass does -------
2147 //
2148 // Run before the allocation phase so that a report's `cleaned` count
2149 // describes the same instant its allocations do. It does not change the
2150 // arithmetic: a terminal attempt already stopped counting against
2151 // capacity when it became terminal, which is `b1`'s
2152 // `counts_against_capacity`. It touches no live process, so it is also
2153 // safe during an outage — flow 3.3 requires that running runners be
2154 // retained, and nothing here can reach one.
2155 self.clean_terminal_attempts(&mut report).await;
2156
2157 // --- Flow 2.3-2.6: the allocation -----------------------------------
2158 //
2159 // The predicates are re-tested here rather than the reading being looked
2160 // up by target, and that is not redundancy. **Targets are shared.** A
2161 // monitor-only policy watching `acme/app` alongside an autoscale policy
2162 // on the *same* repository finds a reading in the map that the other
2163 // policy paid for, and a lookup-driven loop then serves it: it emits a
2164 // demand observation on its behalf and clamps a number it has no
2165 // business seeing.
2166 //
2167 // Nothing downstream goes wrong when that happens — `may_start_runners`
2168 // is false for a monitor-only policy, so `HostAllocator` refuses it and
2169 // `to_start` is zero. It simply is not *skipped*, and D19's word is
2170 // "entirely".
2171 for policy in policies {
2172 if !policy.owns_runners() {
2173 // Already recorded and reported above, before any demand request
2174 // was issued. It owns no routing labels, takes no part in
2175 // demand, and can never be the reason a runner starts. Asserted
2176 // on the mode rather than deduced from `max_capacity` being
2177 // absent, which is what the specification requires.
2178 continue;
2179 }
2180 if !policy.is_owned_by(self.host.id) || !policy.may_start_runners() {
2181 // Ownership rule 2 and precedence rule 4. Allocated for with no
2182 // demand, so the refusal is reported by name rather than by
2183 // absence.
2184 match self.allocate_only(policy, 0, &launched).await {
2185 Ok(allocation) => {
2186 self.emit_allocation(&allocation);
2187 report.allocations.push(allocation);
2188 }
2189 Err(failure) => self.report_unreadable_attempts(&mut report, &failure),
2190 }
2191 continue;
2192 }
2193 if supervision_failed.contains(&policy.id) {
2194 continue;
2195 }
2196 let Some(reading) = readings.get(&policy.target) else {
2197 // Unreachable: every policy reaching here was in `pollable`, and
2198 // `poll_targets` inserts an outcome for each of their targets.
2199 debug_assert!(false, "a pollable policy's target has no reading");
2200 continue;
2201 };
2202 match reading {
2203 PollOutcome::Failed(state) => {
2204 report.unreadable.push(policy.id);
2205 self.events.emit(LifecycleEvent::TargetUnreadable {
2206 policy: policy.id,
2207 reason: unreadable_reason(state),
2208 });
2209 }
2210 PollOutcome::Ready(demand) => {
2211 let tally = routed
2212 .get(&policy.target)
2213 .and_then(|tallies| tallies.get(&policy.id))
2214 .expect("every pollable profile has a routed tally");
2215 let count = tally.demand();
2216 self.events.emit(LifecycleEvent::DemandObserved {
2217 policy: policy.id,
2218 demand: count,
2219 not_matched: tally.not_matched,
2220 unresolvable: u32::try_from(tally.unresolvable.len()).unwrap_or(u32::MAX),
2221 complete: demand.is_complete(),
2222 });
2223 self.start_runners(policy, count, &mut report, &mut launched)
2224 .await;
2225 }
2226 }
2227 }
2228
2229 // --- Flow 2.1 / 3.3: when to come back ------------------------------
2230 let failure = readings
2231 .values()
2232 .filter_map(PollOutcome::failure)
2233 .max_by_key(|state| severity(state))
2234 .cloned();
2235 let now = self.clock.now();
2236 report.next_poll = self
2237 .schedule
2238 .next_poll(failure.as_ref(), now, self.jitter.as_ref());
2239 report.failure = failure;
2240 // Flow 3.3's fourth obligation: the offline state carries the 24-hour
2241 // bound, and it can only say whether that bound has passed if it is
2242 // given the real elapsed time rather than an estimate from the interval.
2243 if let PollPace::Offline { consecutive } = report.next_poll.pace {
2244 let state = OfflineState::new(consecutive, report.next_poll.delay);
2245 report.offline = Some(match self.schedule.offline_for(now) {
2246 Some(elapsed) => state.since(elapsed),
2247 None => state,
2248 });
2249 }
2250 self.events.emit(LifecycleEvent::PollScheduled {
2251 retry_in_ms: u64::try_from(report.next_poll.delay.as_millis()).unwrap_or(u64::MAX),
2252 pace: report.next_poll.pace,
2253 });
2254
2255 report
2256 }
2257
2258 /// Poll each distinct target once, however many policies share it.
2259 ///
2260 /// Two policies on one repository are one demand request, not two. That is
2261 /// not a micro-optimisation: the budget model in
2262 /// `04-subsystem-contracts.md` prices a *target*, and a loop that spent per
2263 /// policy would quietly exceed the projection `f2` admitted the
2264 /// configuration against.
2265 async fn poll_targets(
2266 &self,
2267 pollable: &[&ScalePolicy],
2268 report: &mut ReconcileReport,
2269 ) -> BTreeMap<ScaleTarget, PollOutcome> {
2270 let targets: BTreeSet<ScaleTarget> = pollable.iter().map(|p| p.target.clone()).collect();
2271
2272 let mut readings = BTreeMap::new();
2273 for target in targets {
2274 let outcome = match self.repositories.scope_for(&target).await {
2275 Ok(scope) => {
2276 report.demand_requests = report
2277 .demand_requests
2278 .saturating_add(demand_requests_per_poll(&scope));
2279 self.demand.poll(&scope).await
2280 }
2281 // The repository list could not be refreshed, so the scope of
2282 // the poll is unknown. Polling a stale or empty scope would
2283 // report a demand number for a set of repositories nobody
2284 // chose, which is worse than reporting that the target could
2285 // not be read.
2286 Err(error) => PollOutcome::Failed(RefreshState::from_error(&error)),
2287 };
2288 readings.insert(target, outcome);
2289 }
2290 readings
2291 }
2292
2293 /// Compute one policy's allocation without creating anything.
2294 ///
2295 /// # Why this is safe without the lock
2296 ///
2297 /// **Not** because it cannot grant — it can, and
2298 /// [`Reconciler::start_runners`] uses it as a pre-check precisely for the
2299 /// number it returns. That was the original reason and this function
2300 /// outgrew it; the reason now is that it *decides* nothing. Nothing is
2301 /// created here, the headroom it read is re-read under the lock before any
2302 /// runtime exists, and the under-lock allocation may only lower what this
2303 /// one proposed. So there is no read-decide-create sequence here to make
2304 /// atomic, and the worst this can be is optimistic — which the lock then
2305 /// corrects.
2306 ///
2307 /// # Errors
2308 /// Whatever [`RunnerLauncher::attempts`] reported. A failure is never the
2309 /// same answer as an empty set.
2310 async fn allocate_only(
2311 &self,
2312 policy: &ScalePolicy,
2313 demand: u32,
2314 launched: &[RunnerAttempt],
2315 ) -> Result<Allocation, LaunchFailure> {
2316 let attempts = self.host_attempts(launched).await?;
2317 let mut allocator = HostAllocator::from_attempts(&self.host, &attempts);
2318 Ok(allocator.allocate(policy, demand))
2319 }
2320
2321 /// Flow 2.4-2.6: start runners for one policy, one lock hold per runtime.
2322 ///
2323 /// # Two stopping conditions, and both are needed
2324 ///
2325 /// The loop re-reads the attempt set under every hold, so the obvious stop
2326 /// is "the allocator granted nothing". That condition **alone does not
2327 /// terminate**, and the failure is not hypothetical — it was measured.
2328 /// Handing the allocator a set that does not include the runners this loop
2329 /// just started (an empty one, a stale one, or a launcher whose journal
2330 /// write has not landed yet) makes every grant look like the first, and the
2331 /// pass starts runners until something outside it intervenes. With the set
2332 /// dropped entirely, the three-consecutive-polls test below does not report
2333 /// three attempts; it *never returns*.
2334 ///
2335 /// So the grant decided on the first hold is also a **budget**. A later hold
2336 /// may lower it — the host may have filled up meanwhile — and can never
2337 /// raise it, which bounds the pass at the number this policy was actually
2338 /// allocated. That is `c2`'s reasoning for `MAX_PAGES` one layer down: the
2339 /// reconciliation loop is the one place in this product that must not be
2340 /// able to wedge, so the bound is structural rather than a consequence of
2341 /// every input being well behaved.
2342 async fn start_runners(
2343 &self,
2344 policy: &ScalePolicy,
2345 demand: u32,
2346 report: &mut ReconcileReport,
2347 launched: &mut Vec<RunnerAttempt>,
2348 ) {
2349 // A lock-free pre-check, for one reason only: the host-wide lock should
2350 // not be taken by a policy that is going to be granted nothing. On an
2351 // idle host with P policies that was P lock acquisitions per poll --
2352 // free under `InProcessAllocationLock`, a `spawn_blocking` and a
2353 // filesystem lock apiece under `FileAllocationLock`.
2354 //
2355 // It is safe because it can only be optimistic. Anything it grants is
2356 // re-decided under the lock below and may be lowered there; the only
2357 // thing it can get wrong in the other direction is refusing a grant that
2358 // headroom freed a moment later would have allowed, which the next poll
2359 // picks up.
2360 let intent = match self.allocate_only(policy, demand, launched).await {
2361 Ok(intent) => intent,
2362 Err(failure) => {
2363 self.report_unreadable_attempts(report, &failure);
2364 return;
2365 }
2366 };
2367
2368 // The allocation that is *reported* is the one taken under the lock when
2369 // a lock was taken, because that is the one that decided anything. The
2370 // pre-check stands in only when no hold was ever obtained.
2371 let mut decided: Option<Allocation> = None;
2372 let mut budget = intent.to_start;
2373
2374 while budget > 0 {
2375 let waiting_since = std::time::Instant::now();
2376 let guard = match self.lock.acquire().await {
2377 Ok(guard) => guard,
2378 Err(_) => {
2379 // Grants, not policies: this is what the policy was owed and
2380 // did not get.
2381 report.deferred = report.deferred.saturating_add(budget);
2382 self.events.emit(LifecycleEvent::AllocationDeferred {
2383 policy: policy.id,
2384 count: budget,
2385 });
2386 break;
2387 }
2388 };
2389 let lock_wait = waiting_since.elapsed();
2390
2391 // The read and the decision are both inside the hold, and so is the
2392 // creation below. Two concurrent passes therefore serialise on the
2393 // whole sequence rather than on the decision alone -- reading the
2394 // headroom outside the lock is the shape in which two policies both
2395 // find room for the last slot.
2396 let attempts = match self.host_attempts(launched).await {
2397 Ok(attempts) => attempts,
2398 Err(failure) => {
2399 drop(guard);
2400 self.report_unreadable_attempts(report, &failure);
2401 break;
2402 }
2403 };
2404 let mut allocator = HostAllocator::from_attempts(&self.host, &attempts);
2405 let allocation = allocator.allocate(policy, demand);
2406
2407 if decided.is_none() {
2408 // The under-lock decision may be smaller than the pre-check, and
2409 // never larger: `min` rather than assignment, so a later hold
2410 // cannot raise the bound either.
2411 budget = budget.min(allocation.to_start);
2412 decided = Some(allocation.clone());
2413 }
2414
2415 // Either stop is sufficient on its own in the well-behaved case;
2416 // neither is sufficient when the launcher lags. See the doc comment.
2417 if allocation.starts_nothing() || budget == 0 {
2418 drop(guard);
2419 break;
2420 }
2421
2422 let created = self
2423 .launcher
2424 .launch(LaunchRequest {
2425 host: &self.host,
2426 policy,
2427 allocation_guard: &guard,
2428 lock_wait,
2429 })
2430 .await;
2431 drop(guard);
2432
2433 match created {
2434 Ok(attempt) => {
2435 let id = attempt.id;
2436 // Carried across policies for the rest of this pass, so the
2437 // host-wide total cannot be computed from a set that is
2438 // missing it. See `RunnerLauncher::launch`.
2439 launched.push(attempt);
2440 report.started = report.started.saturating_add(1);
2441 budget -= 1;
2442 self.events.emit(LifecycleEvent::RunnerStarted {
2443 policy: policy.id,
2444 attempt: id,
2445 });
2446 }
2447 Err(failure) => {
2448 self.events.emit(LifecycleEvent::RunnerStartFailed {
2449 policy: policy.id,
2450 reason: failure_reason_kind(&failure.reason),
2451 });
2452 break;
2453 }
2454 }
2455 }
2456
2457 let allocation = decided.unwrap_or(intent);
2458 self.emit_allocation(&allocation);
2459 report.allocations.push(allocation);
2460 }
2461
2462 /// The attempt set the host holds, plus everything this pass has already
2463 /// created.
2464 ///
2465 /// The merge is by [`RunnerAttempt::id`], so a launcher that makes its
2466 /// launches visible before returning -- which
2467 /// [`RunnerLauncher::launch`] asks for -- contributes each attempt once, and
2468 /// one that lags still cannot hide a runner from the host-wide total. The
2469 /// ceiling therefore holds on the strength of this function rather than on
2470 /// the strength of an implementer honouring a comment.
2471 ///
2472 /// # Errors
2473 /// Whatever [`RunnerLauncher::attempts`] reported.
2474 async fn host_attempts(
2475 &self,
2476 launched: &[RunnerAttempt],
2477 ) -> Result<Vec<RunnerAttempt>, LaunchFailure> {
2478 let mut attempts = self.launcher.attempts().await?;
2479
2480 // Each `launch` creates one runtime, so each must answer with an
2481 // identifier no other attempt has. Two entries sharing one here are two
2482 // runtimes the host-wide total below counts once, which is the ceiling
2483 // failing silently -- so a development build stops at the first
2484 // duplicate instead. `RunnerLauncher::launch` states the requirement;
2485 // this is what makes it findable.
2486 debug_assert!(
2487 launched
2488 .iter()
2489 .map(|attempt| attempt.id)
2490 .collect::<BTreeSet<AttemptId>>()
2491 .len()
2492 == launched.len(),
2493 "`RunnerLauncher::launch` returned an AttemptId this pass had already seen; \
2494 the host ceiling is enforced against a set keyed on that identifier, so a \
2495 duplicate is two runtimes counted as one"
2496 );
2497
2498 let known: BTreeSet<AttemptId> = attempts.iter().map(|attempt| attempt.id).collect();
2499 attempts.extend(
2500 launched
2501 .iter()
2502 .filter(|attempt| !known.contains(&attempt.id))
2503 .cloned(),
2504 );
2505 Ok(attempts)
2506 }
2507
2508 /// The attempt set could not be read, so nothing may be decided from it.
2509 ///
2510 /// Counted rather than swallowed for the reason the module documentation
2511 /// gives: an unreadable set and an idle host produce the same *number* and
2512 /// demand opposite actions, so the difference has to survive into the
2513 /// report.
2514 fn report_unreadable_attempts(&self, report: &mut ReconcileReport, failure: &LaunchFailure) {
2515 report.attempts_unreadable = report.attempts_unreadable.saturating_add(1);
2516 // The variant, never a literal and never the detail. A hand-written
2517 // `"attempts_unreadable"` said only what the event's own name already
2518 // said, and threw away the one thing the field is for -- *which* failure
2519 // it was. `FailureReason::Other` carries free text that must not reach
2520 // an event, which is what `failure_reason_kind` is for and what
2521 // `a_cleanup_that_cannot_succeed_...` pins for the sibling path.
2522 self.events.emit(LifecycleEvent::AttemptsUnreadable {
2523 reason: failure_reason_kind(&failure.reason),
2524 });
2525 }
2526
2527 /// Remove the runtimes of attempts that have already concluded.
2528 ///
2529 /// `is_concluded` and not `is_terminal`: `cleaned` is terminal and already
2530 /// done, and `busy` is not terminal at all. That is what makes it impossible
2531 /// for this path to reach a runner executing a job.
2532 async fn clean_terminal_attempts(&mut self, report: &mut ReconcileReport) {
2533 let attempts = match self.launcher.attempts().await {
2534 Ok(attempts) => attempts,
2535 Err(failure) => {
2536 self.report_unreadable_attempts(report, &failure);
2537 return;
2538 }
2539 };
2540 self.clean_backoff
2541 .retain(&attempts.iter().map(|attempt| attempt.id).collect());
2542 let now = self.clock.now();
2543 for attempt in attempts {
2544 if !attempt.state().is_concluded() {
2545 continue;
2546 }
2547 let Some(outcome) = attempt.outcome() else {
2548 continue;
2549 };
2550 // Backed off after failing: not tried, so neither cleaned nor a
2551 // fresh failure to report.
2552 if !self.clean_backoff.is_due(attempt.id, now) {
2553 continue;
2554 }
2555 let kind = OutcomeKind::of(outcome);
2556 match self.launcher.clean(attempt.id).await {
2557 Ok(()) => {
2558 self.clean_backoff.succeeded(attempt.id);
2559 report.cleaned = report.cleaned.saturating_add(1);
2560 if kind.is_failure() {
2561 report.failures = report.failures.saturating_add(1);
2562 } else if kind == OutcomeKind::IdleExit {
2563 // The surplus case. Counted apart from a failure because
2564 // `g2` renders it apart, and because an operator told
2565 // that a normal surplus exit is an error goes hunting a
2566 // fault that does not exist.
2567 report.idle_exits = report.idle_exits.saturating_add(1);
2568 }
2569 self.events.emit(LifecycleEvent::AttemptCleaned {
2570 policy: attempt.policy_id,
2571 attempt: attempt.id,
2572 outcome: kind,
2573 });
2574 }
2575 // A runtime directory that cannot be removed is retried on every
2576 // poll. Silently, before this arm existed: no event, no counter,
2577 // no report field, so a cleanup that can never succeed was an
2578 // invisible permanent loop. It wedges no capacity -- a terminal
2579 // attempt already stopped counting -- but this module's
2580 // organising principle is the things that go wrong silently, and
2581 // `clean` returns a `Result` precisely so the caller can say
2582 // something. It is retried with per-attempt backoff
2583 // ([`CLEAN_RETRY_INITIAL`]) so the saying is not a flood.
2584 Err(failure) => {
2585 self.clean_backoff.failed(attempt.id, now);
2586 report.clean_failures = report.clean_failures.saturating_add(1);
2587 self.events.emit(LifecycleEvent::AttemptCleanFailed {
2588 policy: attempt.policy_id,
2589 attempt: attempt.id,
2590 reason: failure_reason_kind(&failure.reason),
2591 });
2592 }
2593 }
2594 }
2595 }
2596
2597 /// Reclaim what can be reclaimed for one policy, and nothing else.
2598 ///
2599 /// **A busy attempt is never removed.** `04-subsystem-contracts.md`:
2600 /// *"`busy` cannot transition to cleanup due to a scale-down request"*.
2601 /// Capacity comes back when an attempt reaches a terminal state and at no
2602 /// other time, so a scale-down against a host full of busy runners removes
2603 /// nothing, changes nothing, and says so.
2604 pub async fn scale_down(&self, policy: &ScalePolicy) -> ScaleDownReport {
2605 let mut report = ScaleDownReport::default();
2606 let attempts = match self.launcher.attempts().await {
2607 Ok(attempts) => attempts,
2608 Err(failure) => {
2609 // The same rule as everywhere else, and this was the one place
2610 // it was still broken: an unreadable set is not an empty one,
2611 // and a bare `default()` here reported all zeros -- byte for
2612 // byte an idle host with nothing to reclaim.
2613 self.events.emit(LifecycleEvent::AttemptsUnreadable {
2614 reason: failure_reason_kind(&failure.reason),
2615 });
2616 report.attempts_unreadable = true;
2617 return report;
2618 }
2619 };
2620 for attempt in attempts {
2621 if attempt.policy_id != policy.id {
2622 continue;
2623 }
2624 match attempt.state() {
2625 AttemptState::Busy => {
2626 report.refused_busy = report.refused_busy.saturating_add(1);
2627 self.events.emit(LifecycleEvent::ScaleDownRefused {
2628 policy: policy.id,
2629 attempt: attempt.id,
2630 });
2631 }
2632 state if state.is_concluded() => {
2633 let kind = attempt
2634 .outcome()
2635 .map_or(OutcomeKind::Failed, OutcomeKind::of);
2636 match self.launcher.clean(attempt.id).await {
2637 Ok(()) => {
2638 report.removed = report.removed.saturating_add(1);
2639 self.events.emit(LifecycleEvent::AttemptCleaned {
2640 policy: policy.id,
2641 attempt: attempt.id,
2642 outcome: kind,
2643 });
2644 }
2645 Err(failure) => {
2646 report.clean_failures = report.clean_failures.saturating_add(1);
2647 self.events.emit(LifecycleEvent::AttemptCleanFailed {
2648 policy: policy.id,
2649 attempt: attempt.id,
2650 reason: failure_reason_kind(&failure.reason),
2651 });
2652 }
2653 }
2654 }
2655 AttemptState::Cleaned => {}
2656 // `allocated`, `jit_received`, `starting`, `idle`: live, holding
2657 // a slot, and not this function's to end.
2658 _ => report.retained = report.retained.saturating_add(1),
2659 }
2660 }
2661 report
2662 }
2663
2664 fn emit_allocation(&self, allocation: &Allocation) {
2665 self.events.emit(LifecycleEvent::Allocated {
2666 policy: allocation.policy_id,
2667 demand: allocation.demand,
2668 desired: allocation.desired,
2669 active_owned: allocation.active_owned,
2670 headroom: allocation.headroom_before,
2671 to_start: allocation.to_start,
2672 limiting: allocation.limiting_factor,
2673 });
2674 }
2675}
2676
2677/// One policy's demand, from the reading its target answered with.
2678///
2679/// A repository target tallies its own repository's queued jobs; an organization
2680/// target tallies every repository its scope covered, because one policy watching
2681/// an organization serves any repository in it.
2682///
2683/// # Why this takes a whole policy rather than a target and a label set
2684///
2685/// Because both halves have to come from the same policy, and a signature that
2686/// took them separately made it possible for them not to. The predecessor took a
2687/// `&ScaleTarget` alone and could not filter at all; the obvious repair was to
2688/// add a `&RoutingLabels` beside it, and at three call sites — two of them in
2689/// tests — nothing would have caught passing one policy's target with another
2690/// policy's labels. It compiles, it runs, and it silently serves the wrong
2691/// repository's queue.
2692///
2693/// # A monitor-only policy has no labels, and cannot reach here
2694///
2695/// [`Reconciler::reconcile`] filters on [`ScalePolicy::owns_runners`] before any
2696/// demand request is issued (D19), so the `None` arm is unreachable rather than
2697/// merely unlikely. It returns an empty tally instead of unwrapping, because a
2698/// panic in the reconciliation loop would take the daemon down over a policy
2699/// that was only ever going to start nothing.
2700fn demand_for(policy: &ScalePolicy, reading: &QueuedDemand) -> DemandTally {
2701 let Some(labels) = policy.routing_labels() else {
2702 debug_assert!(
2703 false,
2704 "a monitor-only policy is skipped before the demand poll (D19)"
2705 );
2706 return DemandTally::default();
2707 };
2708
2709 match &policy.target {
2710 ScaleTarget::Repository(repository) => labels.tally(reading.jobs_for(repository)),
2711 ScaleTarget::Organization(_) => labels.tally(reading.jobs()),
2712 }
2713}
2714
2715/// Tally one shared target reading using GitHub's required-label subset rule.
2716/// Refuse ambiguous ownership, including inactive local profiles: disabling an
2717/// isolated profile must not redirect its broad-label demand to a native sibling.
2718fn route_demand(
2719 all_policies: &[ScalePolicy],
2720 profiles: &[&ScalePolicy],
2721 reading: &QueuedDemand,
2722) -> (
2723 BTreeMap<PolicyId, DemandTally>,
2724 BTreeMap<RoutingRefusal, u32>,
2725) {
2726 let mut tallies: BTreeMap<PolicyId, DemandTally> = profiles
2727 .iter()
2728 .map(|policy| (policy.id, demand_for(policy, reading)))
2729 .collect();
2730 let mut refusals: BTreeMap<RoutingRefusal, u32> = BTreeMap::new();
2731 let Some(first) = profiles.first() else {
2732 return (tallies, refusals);
2733 };
2734 let owners: Vec<_> = all_policies
2735 .iter()
2736 .filter(|policy| policy.target == first.target && policy.host_id == first.host_id)
2737 .filter(|policy| policy.owns_runners())
2738 .collect();
2739 let jobs: Vec<&runner_manager_domain::policy::RunsOn> = match &first.target {
2740 ScaleTarget::Repository(repository) => reading.jobs_for(repository).iter().collect(),
2741 ScaleTarget::Organization(_) => reading.jobs().collect(),
2742 };
2743 for job in jobs {
2744 let Ok(_) = job.required_labels() else {
2745 // `demand_for` already preserves this as unresolvable for each profile.
2746 continue;
2747 };
2748 let matched: Vec<PolicyId> = owners
2749 .iter()
2750 .filter(|policy| {
2751 policy
2752 .routing_labels()
2753 .is_some_and(|labels| labels.matches(job).is_match())
2754 })
2755 .map(|policy| policy.id)
2756 .collect();
2757 if matched.len() > 1 {
2758 *refusals
2759 .entry(RoutingRefusal::OverlappingProfiles)
2760 .or_default() += 1;
2761 for id in matched {
2762 if let Some(tally) = tallies.get_mut(&id) {
2763 tally.matched -= 1;
2764 tally.not_matched += 1;
2765 }
2766 }
2767 }
2768 }
2769 (tallies, refusals)
2770}
2771
2772/// How urgently one failure should slow the loop down.
2773///
2774/// Ordering matters only for picking the worst of several targets: an outage
2775/// outranks a rate limit because backing off a socket that is not answering is
2776/// the safer error, and both outrank a per-target rejection that says nothing
2777/// about the credential as a whole.
2778const fn severity(state: &RefreshState) -> u8 {
2779 match state {
2780 RefreshState::Offline => 5,
2781 RefreshState::RateLimited(_) => 4,
2782 RefreshState::LockedOut { .. } => 3,
2783 RefreshState::Unauthorized => 2,
2784 RefreshState::Forbidden { .. } | RefreshState::Failed { .. } => 1,
2785 RefreshState::Cancelled | RefreshState::Ready(_) => 0,
2786 }
2787}
2788
2789/// Why one target could not be read, as a fixed, credential-free name.
2790///
2791/// Deliberately not a [`PollPace`]: a pace describes the *schedule*, which is a
2792/// property of the whole pass, and stamping one onto a single target would have
2793/// meant inventing a `consecutive` count for a target that has none. What an
2794/// event needs here is the reason, and `c3`'s [`RefreshState`] already names it.
2795///
2796/// `RefreshState::Failed` carries GitHub's own message and
2797/// `RefreshState::Forbidden` may carry one too. Neither reaches the event: this
2798/// returns the variant, for the reason [`failure_reason_kind`] states.
2799const fn unreadable_reason(state: &RefreshState) -> &'static str {
2800 match state {
2801 RefreshState::Ready(_) => "ready",
2802 RefreshState::Offline => "offline",
2803 RefreshState::RateLimited(_) => "rate_limited",
2804 RefreshState::LockedOut { .. } => "locked_out",
2805 RefreshState::Unauthorized => "unauthorized",
2806 RefreshState::Forbidden { .. } => "forbidden",
2807 RefreshState::Failed { .. } => "failed",
2808 RefreshState::Cancelled => "cancelled",
2809 }
2810}
2811
2812#[cfg(test)]
2813mod tests {
2814 use super::*;
2815
2816 /// The reading that said `healthy` for 28 hours while nothing worked.
2817 ///
2818 /// A daemon every one of whose targets answered `401` kept writing a fresh
2819 /// `last GitHub contact`, because an unauthorized target is `unreadable`
2820 /// rather than a `failure`. Both that record and the `service status` built
2821 /// on it were used as evidence during the investigation, and both were
2822 /// wrong; see `docs/spikes/token-expiry-and-renewal.md`.
2823 #[test]
2824 fn a_pass_that_reached_no_target_does_not_claim_it_reached_github() {
2825 let mut report = ReconcileReport::default();
2826 assert!(
2827 !report.reached_github(),
2828 "a pass that polled nothing -- every policy draining, owned elsewhere, or \
2829 monitor-only -- reached nobody. This is the case the old guard let through, and \
2830 the only one it ever let through."
2831 );
2832
2833 report.unreadable.push(PolicyId::from_u128(1));
2834 assert!(
2835 !report.reached_github(),
2836 "every target this pass tried was unreadable, so there is no contact to record"
2837 );
2838
2839 report.targets_read = 1;
2840 assert!(
2841 report.reached_github(),
2842 "one target answering is contact, whatever else failed alongside it"
2843 );
2844
2845 // `allocations` deliberately does not count: a policy this host does
2846 // not own is allocated for with no demand and without polling anything,
2847 // so a pass where every poll failed can still carry allocations.
2848 let mut unowned = ReconcileReport::default();
2849 unowned.unreadable.push(PolicyId::from_u128(2));
2850 unowned.allocations.push(Allocation {
2851 policy_id: PolicyId::from_u128(2),
2852 demand: 0,
2853 desired: 0,
2854 active_owned: 0,
2855 headroom_before: 0,
2856 to_start: 0,
2857 limiting_factor: LimitingFactor::Demand,
2858 });
2859 assert!(
2860 !unowned.reached_github(),
2861 "an allocation is not evidence that GitHub answered"
2862 );
2863 }
2864
2865 /// The claim the old guard rested on, checked rather than assumed.
2866 ///
2867 /// `report.failure.is_none()` was believed to be compatible with an
2868 /// all-unauthorized pass. It is not: `unreadable` is pushed only from the
2869 /// `Failed` arm and `failure` is the maximum over every `Failed` reading,
2870 /// so guarding on `failure.is_none() && reached_github()` would have been
2871 /// `failure.is_none()` with extra words. This pins the severity that makes
2872 /// it so, because a future `severity(Unauthorized) == 0` would quietly
2873 /// restore the belief.
2874 #[test]
2875 fn an_unauthorized_target_is_a_failure_and_not_merely_unreadable() {
2876 assert!(
2877 severity(&RefreshState::Unauthorized) > 0,
2878 "an unauthorized reading must survive `max_by_key(severity)` into `report.failure`, \
2879 or a pass where every target was refused would report no failure at all"
2880 );
2881 }
2882
2883 use std::sync::atomic::AtomicUsize;
2884
2885 use std::num::NonZeroU16;
2886
2887 use runner_manager_domain::attempt::PersistedAttempt;
2888 use runner_manager_domain::model::{CachePolicy, HostId};
2889 use runner_manager_domain::policy::PolicyMode;
2890 use runner_manager_domain::workspace::WorkspaceKind;
2891 use runner_manager_github::rest::RateLimited;
2892 use runner_manager_testkit::clock::FakeClock;
2893 use runner_manager_testkit::fixtures;
2894 use runner_manager_testkit::github::FakeGithub;
2895
2896 // =======================================================================
2897 // Fakes
2898 // =======================================================================
2899
2900 fn host_with(capacity: u16) -> Host {
2901 fixtures::host().capacity(capacity).build()
2902 }
2903
2904 fn repo(raw: &str) -> OwnerRepo {
2905 OwnerRepo::parse(raw).expect("a valid OWNER/REPO")
2906 }
2907
2908 /// An `active`, enabled autoscale policy on the fixture host.
2909 use runner_manager_domain::policy::RunsOn;
2910
2911 fn policy(id: u128, target: &str, max: u16) -> ScalePolicy {
2912 fixtures::policy()
2913 .id(PolicyId::from_u128(id))
2914 .repository(target)
2915 .autoscale("home", max)
2916 .active()
2917 .build()
2918 }
2919
2920 /// The host label every policy in these tests carries.
2921 ///
2922 /// `policy` above builds through `fixtures::policy().autoscale("home", …)`,
2923 /// which derives `rm-home-win-x64`. A job fixture that did not carry it
2924 /// would be filtered out as another host's work, so the two are tied
2925 /// together here rather than repeated as a literal at each call site.
2926 const HOST_LABEL: &str = "rm-home-win-x64";
2927
2928 /// `n` queued jobs this host's policies match.
2929 ///
2930 /// The ordinary demand fixture. Since the reversal of the run-counting
2931 /// decision the unit `e1` clamps is a job, so a test wanting demand `n` asks
2932 /// for `n` jobs rather than for `n` runs.
2933 fn jobs(n: usize) -> Vec<RunsOn> {
2934 fixtures::queued_jobs(&[HOST_LABEL], n)
2935 }
2936
2937 /// `e3`, faked: an attempt table and a launch counter, no process anywhere.
2938 #[derive(Debug, Default)]
2939 struct FakeLauncher {
2940 attempts: Mutex<Vec<RunnerAttempt>>,
2941 next_id: AtomicU64,
2942 launches: AtomicUsize,
2943 cleaned: Mutex<Vec<AttemptId>>,
2944 /// Yields this many times between reading the attempt set and recording
2945 /// a new one, so an unserialised allocator has a window to be wrong in.
2946 yields_before_recording: usize,
2947 /// Reports success without the attempt ever becoming visible, which is
2948 /// the shape a slow journal write has. Every grant then looks like the
2949 /// first.
2950 forgetful: bool,
2951 fail_next: Mutex<Option<FailureReason>>,
2952 /// Reports that the attempt set cannot be read at all, which is the one
2953 /// answer a caller must never confuse with an idle host.
2954 attempts_fail: Mutex<bool>,
2955 /// Refuses every cleanup, so the silent-retry path has something to be
2956 /// loud about.
2957 clean_fails: bool,
2958 replacements: Mutex<Vec<ReplacementIntent>>,
2959 }
2960
2961 impl FakeLauncher {
2962 fn new() -> Self {
2963 Self::default()
2964 }
2965
2966 fn with_yields(mut self, yields: usize) -> Self {
2967 self.yields_before_recording = yields;
2968 self
2969 }
2970
2971 fn forgetful() -> Self {
2972 Self {
2973 forgetful: true,
2974 ..Self::default()
2975 }
2976 }
2977
2978 fn seeded(self, attempts: Vec<RunnerAttempt>) -> Self {
2979 *self.attempts.lock().unwrap() = attempts;
2980 self
2981 }
2982
2983 fn launches(&self) -> usize {
2984 self.launches.load(Ordering::SeqCst)
2985 }
2986
2987 fn snapshot(&self) -> Vec<RunnerAttempt> {
2988 self.attempts.lock().unwrap().clone()
2989 }
2990
2991 fn live_count(&self) -> usize {
2992 self.snapshot()
2993 .iter()
2994 .filter(|a| a.counts_against_capacity())
2995 .count()
2996 }
2997
2998 fn fail_next(&self, reason: FailureReason) {
2999 *self.fail_next.lock().unwrap() = Some(reason);
3000 }
3001
3002 fn fail_attempts(&self, failing: bool) {
3003 *self.attempts_fail.lock().unwrap() = failing;
3004 }
3005
3006 fn refusing_cleanup(attempts: Vec<RunnerAttempt>) -> Self {
3007 Self {
3008 clean_fails: true,
3009 ..Self::default()
3010 }
3011 .seeded(attempts)
3012 }
3013
3014 fn cleaned(&self) -> Vec<AttemptId> {
3015 self.cleaned.lock().unwrap().clone()
3016 }
3017
3018 fn replacing(self, intent: ReplacementIntent) -> Self {
3019 self.replacements.lock().unwrap().push(intent);
3020 self
3021 }
3022 }
3023
3024 #[async_trait::async_trait]
3025 impl RunnerLauncher for FakeLauncher {
3026 async fn supervise(
3027 &self,
3028 policy: &ScalePolicy,
3029 ) -> Result<Vec<ReplacementIntent>, LaunchFailure> {
3030 let mut replacements = self.replacements.lock().unwrap();
3031 let selected: Vec<_> = replacements
3032 .extract_if(.., |intent| intent.policy == policy.id)
3033 .collect();
3034 if !selected.is_empty() {
3035 let retired: BTreeSet<_> = selected
3036 .iter()
3037 .map(|intent| intent.previous_attempt)
3038 .collect();
3039 self.attempts
3040 .lock()
3041 .unwrap()
3042 .retain(|attempt| !retired.contains(&attempt.id));
3043 }
3044 Ok(selected)
3045 }
3046
3047 async fn attempts(&self) -> Result<Vec<RunnerAttempt>, LaunchFailure> {
3048 if *self.attempts_fail.lock().unwrap() {
3049 return Err(LaunchFailure::new(FailureReason::Other(
3050 "the journal could not be read".into(),
3051 )));
3052 }
3053 Ok(self.snapshot())
3054 }
3055
3056 async fn launch(&self, request: LaunchRequest<'_>) -> Result<RunnerAttempt, LaunchFailure> {
3057 if let Some(reason) = self.fail_next.lock().unwrap().take() {
3058 return Err(LaunchFailure::new(reason));
3059 }
3060 // The window an unserialised caller would lose the race in.
3061 for _ in 0..self.yields_before_recording {
3062 tokio::task::yield_now().await;
3063 }
3064 let id =
3065 AttemptId::from_u128(u128::from(self.next_id.fetch_add(1, Ordering::SeqCst) + 1));
3066 let created = RunnerAttempt::allocate(
3067 id,
3068 request.policy.id,
3069 "runtime/p/a",
3070 request.host.created_at,
3071 );
3072 self.launches.fetch_add(1, Ordering::SeqCst);
3073 if !self.forgetful {
3074 self.attempts.lock().unwrap().push(created.clone());
3075 }
3076 Ok(created)
3077 }
3078
3079 async fn clean(&self, attempt: AttemptId) -> Result<(), LaunchFailure> {
3080 if self.clean_fails {
3081 return Err(LaunchFailure::new(FailureReason::Other(
3082 "the runtime directory is locked".into(),
3083 )));
3084 }
3085 self.cleaned.lock().unwrap().push(attempt);
3086 let mut attempts = self.attempts.lock().unwrap();
3087 attempts.retain(|a| a.id != attempt);
3088 Ok(())
3089 }
3090 }
3091
3092 /// A demand source a test programs directly, with no gateway underneath.
3093 #[derive(Debug, Default)]
3094 struct FakeDemand {
3095 outcome: Mutex<Option<PollOutcome>>,
3096 /// Answers programmed for one target, which beat the blanket one.
3097 per_target: Mutex<BTreeMap<ScaleTarget, PollOutcome>>,
3098 scopes: Mutex<Vec<ActivityScope>>,
3099 }
3100
3101 impl FakeDemand {
3102 fn ready(count: u32, repository: &OwnerRepo) -> Self {
3103 let fake = Self::default();
3104 fake.set(PollOutcome::Ready(QueuedDemand::of(
3105 repository.clone(),
3106 jobs(count as usize),
3107 )));
3108 fake
3109 }
3110
3111 fn failing(state: RefreshState) -> Self {
3112 let fake = Self::default();
3113 fake.set(PollOutcome::Failed(state));
3114 fake
3115 }
3116
3117 fn set(&self, outcome: PollOutcome) {
3118 *self.outcome.lock().unwrap() = Some(outcome);
3119 }
3120
3121 /// Program one target's answer, overriding the blanket one.
3122 fn set_for(&self, target: &ScaleTarget, outcome: PollOutcome) {
3123 self.per_target
3124 .lock()
3125 .unwrap()
3126 .insert(target.clone(), outcome);
3127 }
3128
3129 fn polls(&self) -> Vec<ActivityScope> {
3130 self.scopes.lock().unwrap().clone()
3131 }
3132 }
3133
3134 #[async_trait::async_trait]
3135 impl DemandSource for FakeDemand {
3136 async fn poll(&self, scope: &ActivityScope) -> PollOutcome {
3137 self.scopes.lock().unwrap().push(scope.clone());
3138 if let Some(outcome) = self.per_target.lock().unwrap().get(scope.target()) {
3139 return outcome.clone();
3140 }
3141 self.outcome
3142 .lock()
3143 .unwrap()
3144 .clone()
3145 .unwrap_or(PollOutcome::Ready(QueuedDemand::default()))
3146 }
3147 }
3148
3149 #[derive(Debug, Default)]
3150 struct FakeDirectory {
3151 repositories: Vec<OwnerRepo>,
3152 calls: AtomicUsize,
3153 }
3154
3155 impl FakeDirectory {
3156 fn of(repositories: Vec<OwnerRepo>) -> Self {
3157 Self {
3158 repositories,
3159 calls: AtomicUsize::new(0),
3160 }
3161 }
3162
3163 fn calls(&self) -> usize {
3164 self.calls.load(Ordering::SeqCst)
3165 }
3166 }
3167
3168 #[async_trait::async_trait]
3169 impl RepositoryDirectory for FakeDirectory {
3170 async fn repositories(&self, _org: &Org) -> Result<Vec<OwnerRepo>, InventoryError> {
3171 self.calls.fetch_add(1, Ordering::SeqCst);
3172 Ok(self.repositories.clone())
3173 }
3174 }
3175
3176 /// A lock that grants everything and counts how many holders it had at once.
3177 ///
3178 /// The counter is the assertion: "under simulated lock contention" is only
3179 /// meaningful if something measures that the contention was actually
3180 /// serialised.
3181 #[derive(Debug)]
3182 struct CountingLock {
3183 inner: InProcessAllocationLock,
3184 concurrent: Arc<AtomicUsize>,
3185 peak: Arc<AtomicUsize>,
3186 acquisitions: Arc<AtomicUsize>,
3187 }
3188
3189 impl CountingLock {
3190 fn new() -> Self {
3191 Self {
3192 inner: InProcessAllocationLock::new(),
3193 concurrent: Arc::new(AtomicUsize::new(0)),
3194 peak: Arc::new(AtomicUsize::new(0)),
3195 acquisitions: Arc::new(AtomicUsize::new(0)),
3196 }
3197 }
3198
3199 fn peak(&self) -> usize {
3200 self.peak.load(Ordering::SeqCst)
3201 }
3202
3203 fn acquisitions(&self) -> usize {
3204 self.acquisitions.load(Ordering::SeqCst)
3205 }
3206 }
3207
3208 #[derive(Debug)]
3209 struct CountingGuard {
3210 _inner: AllocationGuard,
3211 concurrent: Arc<AtomicUsize>,
3212 }
3213
3214 impl Drop for CountingGuard {
3215 fn drop(&mut self) {
3216 self.concurrent.fetch_sub(1, Ordering::SeqCst);
3217 }
3218 }
3219
3220 #[async_trait::async_trait]
3221 impl AllocationLock for CountingLock {
3222 async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
3223 let inner = self.inner.acquire().await?;
3224 self.acquisitions.fetch_add(1, Ordering::SeqCst);
3225 let now = self.concurrent.fetch_add(1, Ordering::SeqCst) + 1;
3226 self.peak.fetch_max(now, Ordering::SeqCst);
3227 Ok(AllocationGuard::new(CountingGuard {
3228 _inner: inner,
3229 concurrent: Arc::clone(&self.concurrent),
3230 }))
3231 }
3232 }
3233
3234 /// The lock that is not one: what the host looks like with the serialisation
3235 /// removed. Used only by the control half of the contention test.
3236 #[derive(Debug, Default)]
3237 struct NoLock;
3238
3239 #[async_trait::async_trait]
3240 impl AllocationLock for NoLock {
3241 async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
3242 Ok(AllocationGuard::new(()))
3243 }
3244 }
3245
3246 #[tokio::test]
3247 async fn a_wsl_drain_request_fences_the_exact_pre_launch_boundary() {
3248 use runner_manager_platform::paths::AppPaths;
3249 use runner_manager_platform::wsl::fence::{DrainRequest, GuestRecoveryConfig};
3250
3251 let local = tempfile::tempdir().unwrap();
3252 let shared = tempfile::tempdir().unwrap();
3253 let paths = Arc::new(AppPaths::rooted_at(local.path()));
3254 paths.create_all().unwrap();
3255 GuestRecoveryConfig::new(shared.path().to_path_buf())
3256 .write(&paths)
3257 .unwrap();
3258 DrainRequest::new(4, chrono::Utc::now())
3259 .write(shared.path())
3260 .unwrap();
3261 let lock = WslRecoveryAllocationLock::new(paths, Arc::new(NoLock));
3262
3263 assert!(lock.acquire().await.is_err());
3264 }
3265
3266 #[tokio::test]
3267 async fn a_guest_launch_claim_is_released_with_its_allocation_guard() {
3268 use runner_manager_platform::paths::AppPaths;
3269 use runner_manager_platform::wsl::fence::{FENCE_DIRECTORY, GuestRecoveryConfig};
3270
3271 let local = tempfile::tempdir().unwrap();
3272 let shared = tempfile::tempdir().unwrap();
3273 let paths = Arc::new(AppPaths::rooted_at(local.path()));
3274 paths.create_all().unwrap();
3275 GuestRecoveryConfig::new(shared.path().to_path_buf())
3276 .write(&paths)
3277 .unwrap();
3278 let lock = WslRecoveryAllocationLock::new(paths, Arc::new(NoLock));
3279 let guard = lock.acquire().await.unwrap();
3280 assert!(shared.path().join(FENCE_DIRECTORY).exists());
3281 drop(guard);
3282 assert!(!shared.path().join(FENCE_DIRECTORY).exists());
3283 }
3284
3285 /// A lock nobody can take.
3286 #[derive(Debug, Default)]
3287 struct HeldLock;
3288
3289 #[async_trait::async_trait]
3290 impl AllocationLock for HeldLock {
3291 async fn acquire(&self) -> Result<AllocationGuard, AllocationLockBusy> {
3292 Err(AllocationLockBusy)
3293 }
3294 }
3295
3296 /// Everything one test needs, wired together.
3297 struct Harness {
3298 launcher: Arc<FakeLauncher>,
3299 demand: Arc<FakeDemand>,
3300 events: Arc<EventLog>,
3301 clock: Arc<FakeClock>,
3302 reconciler: Reconciler,
3303 }
3304
3305 impl Harness {
3306 fn build(
3307 host: Host,
3308 launcher: Arc<FakeLauncher>,
3309 demand: Arc<FakeDemand>,
3310 lock: Arc<dyn AllocationLock>,
3311 ) -> Self {
3312 let events = Arc::new(EventLog::new());
3313 let clock = Arc::new(FakeClock::default());
3314 let reconciler = Reconciler::new(
3315 host,
3316 ReconcilerPorts {
3317 demand: Arc::clone(&demand) as Arc<dyn DemandSource>,
3318 launcher: Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
3319 lock,
3320 directory: Arc::new(FakeDirectory::default()),
3321 clock: Arc::clone(&clock) as Arc<dyn Clock>,
3322 jitter: Arc::new(NoJitter) as Arc<dyn Jitter>,
3323 events: Arc::clone(&events) as Arc<dyn EventSink>,
3324 },
3325 );
3326 Self {
3327 launcher,
3328 demand,
3329 events,
3330 clock,
3331 reconciler,
3332 }
3333 }
3334
3335 fn simple(capacity: u16, demand_count: u32, target: &str) -> Self {
3336 let launcher = Arc::new(FakeLauncher::new());
3337 let demand = Arc::new(FakeDemand::ready(demand_count, &repo(target)));
3338 Self::build(
3339 host_with(capacity),
3340 launcher,
3341 demand,
3342 Arc::new(InProcessAllocationLock::new()),
3343 )
3344 }
3345 }
3346
3347 fn attempt_in(state: AttemptState, id: u128, policy: u128) -> RunnerAttempt {
3348 let outcome = state.is_terminal().then(|| match state {
3349 AttemptState::Failed => {
3350 AttemptOutcome::failed(FailureReason::ProcessExitedUnexpectedly)
3351 }
3352 AttemptState::Orphaned => AttemptOutcome::Orphaned,
3353 _ => AttemptOutcome::CompletedJob,
3354 });
3355 RunnerAttempt::from_persisted(PersistedAttempt {
3356 id: AttemptId::from_u128(id),
3357 policy_id: PolicyId::from_u128(policy),
3358 github_runner_id: None,
3359 state,
3360 outcome,
3361 process_id: None,
3362 execution: runner_manager_domain::execution::AttemptExecution::Native {
3363 process_id: None,
3364 },
3365 runtime_path: "runtime/p/a".into(),
3366 workspace_kind: WorkspaceKind::Ephemeral,
3367 workspace_slot: None,
3368 created_at: fixtures::created_at(),
3369 terminal_at: state.is_terminal().then(fixtures::created_at),
3370 last_state_change_at: fixtures::created_at(),
3371 })
3372 .expect("a state/outcome pair the domain accepts")
3373 }
3374
3375 /// A concluded attempt carrying a specific outcome.
3376 fn concluded(id: u128, policy: u128, outcome: AttemptOutcome) -> RunnerAttempt {
3377 RunnerAttempt::from_persisted(PersistedAttempt {
3378 id: AttemptId::from_u128(id),
3379 policy_id: PolicyId::from_u128(policy),
3380 github_runner_id: None,
3381 state: outcome.terminal_state(),
3382 outcome: Some(outcome),
3383 process_id: None,
3384 execution: runner_manager_domain::execution::AttemptExecution::Native {
3385 process_id: None,
3386 },
3387 runtime_path: "runtime/p/a".into(),
3388 workspace_kind: WorkspaceKind::Ephemeral,
3389 workspace_slot: None,
3390 created_at: fixtures::created_at(),
3391 terminal_at: Some(fixtures::created_at()),
3392 last_state_change_at: fixtures::created_at(),
3393 })
3394 .expect("a state/outcome pair the domain accepts")
3395 }
3396
3397 // =======================================================================
3398 // The in-flight term: the single most likely way this task goes wrong
3399 // =======================================================================
3400
3401 /// `e1`'s Definition of Done, verbatim: *"A job that remains `queued` across
3402 /// three consecutive polls while its attempt is `starting` yields exactly
3403 /// one attempt — the test fails if the in-flight term is dropped from the
3404 /// formula."*
3405 ///
3406 /// `b1` tests the arithmetic underneath this
3407 /// (`capacity::tests::the_same_queued_job_on_two_polls_yields_one_attempt_
3408 /// not_two`). What *this* test covers is the only way `e1` can drop the
3409 /// term without touching `b1` at all: handing the allocator an attempt set
3410 /// that is not the one the host holds.
3411 ///
3412 /// # This was measured, not assumed, and the first measurement was worse
3413 /// # than the failure it was looking for
3414 ///
3415 /// Replacing `self.launcher.attempts().await` in
3416 /// [`Reconciler::start_runners`] with `Vec::new()` compiles and runs. Before
3417 /// that function carried a budget, this test did not go red — it **never
3418 /// returned**: every grant looked like the first, so the pass started
3419 /// runners forever inside poll 1. That is the runaway-runner failure exactly
3420 /// as an operator would meet it, and it is why the budget exists.
3421 ///
3422 /// With the budget in place the same injection fails cleanly and says what
3423 /// happened: `poll 2 … left: 2, right: 1`. Both measurements were run
3424 /// before this assertion was written.
3425 #[tokio::test]
3426 async fn three_polls_of_one_still_queued_run_yield_exactly_one_attempt() {
3427 let mut harness = Harness::simple(4, 1, "acme/app");
3428 let policy = policy(1, "acme/app", 4);
3429
3430 for poll in 1..=3 {
3431 let report = harness
3432 .reconciler
3433 .reconcile(std::slice::from_ref(&policy))
3434 .await;
3435 assert_eq!(
3436 harness.launcher.launches(),
3437 1,
3438 "poll {poll} started another runner for a job already being served; the \
3439 `- active_owned_runners` term reached `HostAllocator` as a set this host \
3440 does not hold"
3441 );
3442 assert_eq!(report.allocations.len(), 1);
3443 let allocation = &report.allocations[0];
3444 assert_eq!(allocation.demand, 1, "poll {poll}: still queued at GitHub");
3445 if poll == 1 {
3446 assert_eq!(allocation.to_start, 1);
3447 assert_eq!(report.started, 1);
3448 } else {
3449 assert_eq!(allocation.active_owned, 1, "poll {poll}");
3450 assert_eq!(allocation.to_start, 0, "poll {poll}");
3451 assert_eq!(report.started, 0, "poll {poll}");
3452 }
3453 }
3454 assert_eq!(harness.launcher.live_count(), 1);
3455 }
3456
3457 /// The other half of the measurement above: the loop must terminate even
3458 /// when the attempt set never catches up with it.
3459 ///
3460 /// Dropping the in-flight term made
3461 /// `three_polls_of_one_still_queued_run_yield_exactly_one_attempt` hang
3462 /// rather than fail — the loop had one stopping condition and it was the one
3463 /// the bug removed. A launcher whose journal write has not landed presents
3464 /// exactly the same shape without any bug at all, so the budget in
3465 /// [`Reconciler::start_runners`] bounds the pass structurally. This is what
3466 /// asserts the bound is really there.
3467 #[tokio::test]
3468 async fn a_launcher_whose_attempts_never_appear_cannot_wedge_the_pass() {
3469 let launcher = Arc::new(FakeLauncher::forgetful());
3470 let mut harness = Harness::build(
3471 host_with(64),
3472 Arc::clone(&launcher),
3473 Arc::new(FakeDemand::ready(3, &repo("acme/app"))),
3474 Arc::new(InProcessAllocationLock::new()),
3475 );
3476
3477 let report = harness
3478 .reconciler
3479 .reconcile(&[policy(1, "acme/app", 8)])
3480 .await;
3481
3482 assert_eq!(
3483 report.started, 3,
3484 "the pass is bounded by the grant it was given, not by the attempt set catching \
3485 up with it"
3486 );
3487 assert_eq!(launcher.launches(), 3);
3488 assert!(
3489 launcher.snapshot().is_empty(),
3490 "the launcher never recorded anything, which is the whole point of the fixture"
3491 );
3492 }
3493
3494 /// The host-wide ceiling must hold across policies even when the launcher
3495 /// lags, and the per-policy budget alone does not reach that case.
3496 ///
3497 /// Review found this, with this file's own `forgetful` fixture and one more
3498 /// policy: the budget bounds *each policy's* loop to its own first grant,
3499 /// but policy B's first grant is computed from a set that does not yet
3500 /// contain policy A's launches, so B's bound is itself too large. Two
3501 /// policies on a host of three started **six** runners --
3502 /// `host_capacity=3, started=6, launches=6` -- with the lock held correctly
3503 /// throughout. Serialisation was never the problem; the arithmetic under it
3504 /// was reading a stale set.
3505 #[tokio::test]
3506 async fn two_policies_cannot_exceed_host_capacity_even_when_the_launcher_lags() {
3507 let launcher = Arc::new(FakeLauncher::forgetful());
3508 let demand = Arc::new(FakeDemand::default());
3509 demand.set_for(
3510 &ScaleTarget::repository("acme/left").unwrap(),
3511 PollOutcome::Ready(QueuedDemand::of(repo("acme/left"), jobs(3))),
3512 );
3513 demand.set_for(
3514 &ScaleTarget::repository("acme/right").unwrap(),
3515 PollOutcome::Ready(QueuedDemand::of(repo("acme/right"), jobs(3))),
3516 );
3517 let mut harness = Harness::build(
3518 host_with(3),
3519 Arc::clone(&launcher),
3520 demand,
3521 Arc::new(InProcessAllocationLock::new()),
3522 );
3523
3524 let report = harness
3525 .reconciler
3526 .reconcile(&[policy(1, "acme/left", 3), policy(2, "acme/right", 3)])
3527 .await;
3528
3529 assert_eq!(
3530 report.started, 3,
3531 "host_capacity is 3 and two policies each allowed 3 started {} runners \
3532 between them; the second policy's grant was computed from a set that did \
3533 not yet contain the first policy's launches",
3534 report.started
3535 );
3536 assert_eq!(launcher.launches(), 3);
3537 }
3538
3539 /// Finding 1: an attempt set that cannot be read is not an empty one.
3540 ///
3541 /// `attempts()` used to be infallible, which left `e3` — reading a journal
3542 /// off a disk — a choice between panicking and answering `vec![]`. The
3543 /// second is silent and catastrophic: an empty set is indistinguishable from
3544 /// an idle host, so a transient read failure reads as "nothing is running"
3545 /// and the pass allocates the whole machine for jobs already being served.
3546 ///
3547 /// The contrast is the assertion. Identical host, identical demand,
3548 /// identical policy; the only difference is whether the launcher can answer.
3549 #[tokio::test]
3550 async fn an_unreadable_attempt_set_starts_nothing_and_is_not_read_as_an_idle_host() {
3551 let launcher = Arc::new(FakeLauncher::new());
3552 let mut harness = Harness::build(
3553 host_with(8),
3554 Arc::clone(&launcher),
3555 Arc::new(FakeDemand::ready(4, &repo("acme/app"))),
3556 Arc::new(InProcessAllocationLock::new()),
3557 );
3558 let policy = policy(1, "acme/app", 8);
3559
3560 launcher.fail_attempts(true);
3561 let unreadable = harness
3562 .reconciler
3563 .reconcile(std::slice::from_ref(&policy))
3564 .await;
3565
3566 assert_eq!(
3567 unreadable.started, 0,
3568 "nothing may be decided from a set that was not read"
3569 );
3570 assert_eq!(launcher.launches(), 0);
3571 assert!(unreadable.attempts_unreadable > 0, "and the pass says so");
3572 assert!(
3573 unreadable.allocations.is_empty(),
3574 "no allocation is reported either: there was no set to compute one from, and \
3575 an allocation of zero would claim a decision nobody made"
3576 );
3577 assert!(harness.events.count_of("attempts_unreadable") > 0);
3578
3579 // The same everything, with a launcher that can answer.
3580 launcher.fail_attempts(false);
3581 let readable = harness
3582 .reconciler
3583 .reconcile(std::slice::from_ref(&policy))
3584 .await;
3585 assert_eq!(
3586 readable.started, 4,
3587 "the difference between the two passes is only whether the set could be read"
3588 );
3589 assert_eq!(readable.attempts_unreadable, 0);
3590 }
3591
3592 /// Finding 3: a cleanup that can never succeed was an invisible permanent
3593 /// loop.
3594 ///
3595 /// `if …clean(…).await.is_ok()` had no `else`, so a runtime directory that
3596 /// could not be removed was retried on every poll with no event, no counter
3597 /// and no report field. It wedges no capacity — a terminal attempt already
3598 /// stopped counting — but `clean` returns a `Result` precisely so the caller
3599 /// can say something, and this module's organising principle is the things
3600 /// that go wrong silently.
3601 #[tokio::test]
3602 async fn a_cleanup_that_cannot_succeed_is_reported_rather_than_retried_in_silence() {
3603 let launcher = Arc::new(FakeLauncher::refusing_cleanup(vec![concluded(
3604 1,
3605 1,
3606 AttemptOutcome::ExitedIdleWithoutWork,
3607 )]));
3608 let mut harness = Harness::build(
3609 host_with(4),
3610 Arc::clone(&launcher),
3611 Arc::new(FakeDemand::ready(0, &repo("acme/app"))),
3612 Arc::new(InProcessAllocationLock::new()),
3613 );
3614
3615 let report = harness
3616 .reconciler
3617 .reconcile(&[policy(1, "acme/app", 4)])
3618 .await;
3619
3620 assert_eq!(report.cleaned, 0);
3621 assert_eq!(report.clean_failures, 1);
3622 assert_eq!(harness.events.count_of("attempt_clean_failed"), 1);
3623 assert_eq!(
3624 harness.events.count_of("attempt_cleaned"),
3625 0,
3626 "and it is not reported as cleaned"
3627 );
3628 assert_eq!(
3629 launcher.snapshot().len(),
3630 1,
3631 "the attempt is still there, so the retry is real -- what changed is that it \
3632 is no longer silent"
3633 );
3634
3635 // The reason is the variant, never the detail: the fixture's failure
3636 // carries free text and none of it reaches the event.
3637 let reasons: Vec<&'static str> = harness
3638 .events
3639 .events()
3640 .into_iter()
3641 .filter_map(|event| match event {
3642 LifecycleEvent::AttemptCleanFailed { reason, .. } => Some(reason),
3643 _ => None,
3644 })
3645 .collect();
3646 assert_eq!(reasons, vec!["other"]);
3647 }
3648
3649 /// A cleanup that keeps failing is retried with per-attempt exponential
3650 /// backoff rather than on every pass, and a success forgets the backoff.
3651 #[tokio::test]
3652 async fn a_failing_cleanup_backs_off_exponentially_per_attempt() {
3653 let launcher = Arc::new(FakeLauncher::refusing_cleanup(vec![concluded(
3654 1,
3655 1,
3656 AttemptOutcome::ExitedIdleWithoutWork,
3657 )]));
3658 let mut harness = Harness::build(
3659 host_with(4),
3660 Arc::clone(&launcher),
3661 Arc::new(FakeDemand::ready(0, &repo("acme/app"))),
3662 Arc::new(InProcessAllocationLock::new()),
3663 );
3664 let policies = [policy(1, "acme/app", 4)];
3665 let start = harness.clock.now();
3666 let at = |offset: Duration| start + chrono::Duration::from_std(offset).unwrap();
3667 let mut failures_at = async |offset: Duration| {
3668 harness.clock.set(at(offset));
3669 harness.reconciler.reconcile(&policies).await.clean_failures
3670 };
3671
3672 assert_eq!(failures_at(Duration::ZERO).await, 1);
3673 // Inside the first delay: not tried, so nothing new to report.
3674 assert_eq!(
3675 failures_at(CLEAN_RETRY_INITIAL - Duration::from_secs(1)).await,
3676 0,
3677 "a failed cleanup was retried on the very next pass"
3678 );
3679 assert_eq!(failures_at(CLEAN_RETRY_INITIAL).await, 1);
3680 // The second delay is twice the first.
3681 let second = CLEAN_RETRY_INITIAL + CLEAN_RETRY_INITIAL * 2;
3682 assert_eq!(
3683 failures_at(second - Duration::from_secs(1)).await,
3684 0,
3685 "the delay did not double after a second failure"
3686 );
3687 assert_eq!(failures_at(second).await, 1);
3688 assert_eq!(harness.events.count_of("attempt_clean_failed"), 3);
3689
3690 // The delay never exceeds the ceiling, however many failures accrue.
3691 let mut backoff = CleanBackoff::default();
3692 let id = AttemptId::from_u128(9);
3693 for _ in 0..40 {
3694 backoff.failed(id, start);
3695 }
3696 assert!(!backoff.is_due(id, at(CLEAN_RETRY_MAXIMUM - Duration::from_secs(1))));
3697 assert!(backoff.is_due(id, at(CLEAN_RETRY_MAXIMUM)));
3698 backoff.succeeded(id);
3699 assert!(
3700 backoff.is_due(id, start),
3701 "a cleaned attempt kept its backoff"
3702 );
3703 }
3704
3705 /// N2: an unreadable attempt set makes a scale-down inconclusive, not empty.
3706 ///
3707 /// Making `attempts()` fallible closed this everywhere the allocation path
3708 /// touches, and left it open in the one place that returns a different type:
3709 /// `scale_down` answered `ScaleDownReport::default()`, which is all zeros
3710 /// and byte-for-byte identical to an idle host with nothing to reclaim. The
3711 /// two mean opposite things — "there was nothing to remove" against "we
3712 /// cannot see what there was".
3713 ///
3714 /// Measured as the sibling test measures it: identical host, identical
3715 /// attempts, identical policy, and the only difference is whether the
3716 /// launcher can answer.
3717 #[tokio::test]
3718 async fn an_unreadable_attempt_set_makes_scale_down_inconclusive_rather_than_empty() {
3719 let launcher = Arc::new(FakeLauncher::new().seeded(vec![
3720 attempt_in(AttemptState::Busy, 1, 1),
3721 concluded(2, 1, AttemptOutcome::CompletedJob),
3722 ]));
3723 let harness = Harness::build(
3724 host_with(4),
3725 Arc::clone(&launcher),
3726 Arc::new(FakeDemand::ready(0, &repo("acme/app"))),
3727 Arc::new(InProcessAllocationLock::new()),
3728 );
3729 let policy = policy(1, "acme/app", 4);
3730
3731 launcher.fail_attempts(true);
3732 let blind = harness.reconciler.scale_down(&policy).await;
3733
3734 assert!(!blind.is_conclusive(), "the machine was never read");
3735 assert_ne!(
3736 blind,
3737 ScaleDownReport::default(),
3738 "a scale-down that could not see the host must not be equal to one that saw \
3739 an idle host; that equality is the whole finding"
3740 );
3741 assert_eq!(blind.removed, 0);
3742 assert_eq!(
3743 blind.refused_busy, 0,
3744 "and this zero means `unknown`, not `none`"
3745 );
3746 assert_eq!(harness.events.count_of("attempts_unreadable"), 1);
3747
3748 // The same everything, with a launcher that can answer.
3749 launcher.fail_attempts(false);
3750 let seeing = harness.reconciler.scale_down(&policy).await;
3751
3752 assert!(seeing.is_conclusive());
3753 assert_eq!(seeing.removed, 1, "the concluded attempt was reclaimed");
3754 assert_eq!(seeing.refused_busy, 1, "and the busy one was left alone");
3755 assert_ne!(
3756 seeing, blind,
3757 "the difference between the two is only whether the set could be read"
3758 );
3759 }
3760
3761 /// Finding 7: the lower arm of the clamp, driven through the reconciler.
3762 ///
3763 /// `demand_below_min_capacity_starts_nothing_in_v1` runs `demand = 0`
3764 /// against `min_capacity = 0`, which is *at* the floor and never raises
3765 /// `desired` — the assertion held for a reason unrelated to the boundary it
3766 /// named. D7 fixes `min` at 0 for v1, but `AutoscaleConfig::new` accepts
3767 /// `min > 0` today, so the path is representable and was undriven.
3768 #[tokio::test]
3769 async fn demand_below_min_capacity_is_raised_to_min_capacity() {
3770 let mut warm = ScalePolicy::new(
3771 PolicyId::from_u128(1),
3772 ScaleTarget::repository("acme/app").unwrap(),
3773 1,
3774 fixtures::HOST_ID,
3775 PolicyMode::autoscale(
3776 fixtures::routing_labels("home"),
3777 2,
3778 NonZeroU16::new(5).expect("non-zero"),
3779 )
3780 .expect("min <= max"),
3781 CachePolicy::default(),
3782 );
3783 warm.activate().expect("pending -> active");
3784
3785 let mut harness = Harness::simple(8, 0, "acme/app");
3786 let report = harness.reconciler.reconcile(&[warm]).await;
3787
3788 assert_eq!(
3789 report.allocations[0].demand, 0,
3790 "GitHub reported no queued runs"
3791 );
3792 assert_eq!(
3793 report.allocations[0].desired, 2,
3794 "min_capacity raised the target above demand"
3795 );
3796 assert_eq!(
3797 report.allocations[0].limiting_factor,
3798 LimitingFactor::MinCapacity
3799 );
3800 assert_eq!(report.started, 2, "and two runners were actually started");
3801 assert_eq!(harness.launcher.live_count(), 2);
3802 }
3803
3804 // =======================================================================
3805 // Capacity, at the boundaries
3806 // =======================================================================
3807
3808 #[tokio::test]
3809 async fn demand_above_max_capacity_is_clamped_to_max_capacity() {
3810 let mut harness = Harness::simple(100, 10, "acme/app");
3811 let report = harness
3812 .reconciler
3813 .reconcile(&[policy(1, "acme/app", 3)])
3814 .await;
3815
3816 assert_eq!(report.allocations[0].demand, 10);
3817 assert_eq!(
3818 report.allocations[0].desired, 3,
3819 "max_capacity beats demand"
3820 );
3821 assert_eq!(report.started, 3);
3822 assert_eq!(
3823 report.allocations[0].limiting_factor,
3824 LimitingFactor::MaxCapacity
3825 );
3826 }
3827
3828 #[tokio::test]
3829 async fn demand_below_min_capacity_starts_nothing_in_v1() {
3830 // D7 fixes `min_capacity` at 0, so "below the floor" is "no demand", and
3831 // the product requirement it satisfies is "no idle runners when unused".
3832 let mut harness = Harness::simple(8, 0, "acme/app");
3833 let report = harness
3834 .reconciler
3835 .reconcile(&[policy(1, "acme/app", 4)])
3836 .await;
3837
3838 assert_eq!(report.allocations[0].desired, 0);
3839 assert_eq!(report.started, 0);
3840 assert!(report.starts_nothing());
3841 }
3842
3843 #[tokio::test]
3844 async fn lifecycle_replacement_intent_is_consumed_by_the_ordinary_allocator() {
3845 let policy = policy(1, "octo/repo", 1);
3846 let previous = attempt_in(AttemptState::Starting, 41, 1);
3847 let intent = ReplacementIntent {
3848 policy: policy.id,
3849 previous_attempt: previous.id,
3850 operation: "exit_before_acceptance_replacement",
3851 };
3852 let launcher = Arc::new(FakeLauncher::new().seeded(vec![previous]).replacing(intent));
3853 let demand = Arc::new(FakeDemand::ready(1, &repo("octo/repo")));
3854 let mut harness = Harness::build(
3855 host_with(1),
3856 Arc::clone(&launcher),
3857 demand,
3858 Arc::new(InProcessAllocationLock::new()),
3859 );
3860
3861 let report = harness.reconciler.reconcile(&[policy]).await;
3862
3863 assert_eq!(report.replacement_intents, 1);
3864 assert_eq!(report.started, 1);
3865 assert_eq!(launcher.launches(), 1);
3866 assert_eq!(launcher.live_count(), 1);
3867 }
3868
3869 #[tokio::test]
3870 async fn zero_host_headroom_starts_nothing_at_maximum_demand() {
3871 let launcher = Arc::new(FakeLauncher::new().seeded(vec![
3872 attempt_in(AttemptState::Busy, 1, 1),
3873 attempt_in(AttemptState::Busy, 2, 1),
3874 ]));
3875 let demand = Arc::new(FakeDemand::ready(u32::from(u16::MAX), &repo("acme/app")));
3876 let mut harness = Harness::build(
3877 host_with(2),
3878 launcher,
3879 demand,
3880 Arc::new(InProcessAllocationLock::new()),
3881 );
3882
3883 let report = harness
3884 .reconciler
3885 .reconcile(&[policy(1, "acme/app", 2)])
3886 .await;
3887 assert_eq!(report.started, 0);
3888 assert_eq!(report.allocations[0].headroom_before, 0);
3889 assert_eq!(harness.launcher.launches(), 0);
3890 }
3891
3892 #[tokio::test]
3893 async fn headroom_smaller_than_the_per_policy_allowance_wins() {
3894 // Four slots held by *another* policy on a host of six: this policy is
3895 // allowed five and gets two.
3896 let launcher = Arc::new(FakeLauncher::new().seeded(vec![
3897 attempt_in(AttemptState::Busy, 1, 99),
3898 attempt_in(AttemptState::Busy, 2, 99),
3899 attempt_in(AttemptState::Idle, 3, 99),
3900 attempt_in(AttemptState::Starting, 4, 99),
3901 ]));
3902 let demand = Arc::new(FakeDemand::ready(5, &repo("acme/app")));
3903 let mut harness = Harness::build(
3904 host_with(6),
3905 launcher,
3906 demand,
3907 Arc::new(InProcessAllocationLock::new()),
3908 );
3909
3910 let report = harness
3911 .reconciler
3912 .reconcile(&[policy(1, "acme/app", 5)])
3913 .await;
3914 assert_eq!(
3915 report.allocations[0].desired, 5,
3916 "its own ceiling allows five"
3917 );
3918 assert_eq!(report.started, 2, "the host has two slots free");
3919 assert_eq!(
3920 report.allocations[0].limiting_factor,
3921 LimitingFactor::HostCapacity
3922 );
3923 assert_eq!(harness.launcher.live_count(), 6);
3924 }
3925
3926 #[tokio::test]
3927 async fn the_idle_host_assertion_holds() {
3928 // "No demand means zero runner processes and zero attempts out of
3929 // terminal state."
3930 let mut harness = Harness::simple(8, 0, "acme/app");
3931 let report = harness
3932 .reconciler
3933 .reconcile(&[policy(1, "acme/app", 4), policy(2, "acme/app", 4)])
3934 .await;
3935
3936 assert_eq!(report.started, 0);
3937 assert_eq!(harness.launcher.launches(), 0);
3938 assert!(harness.launcher.snapshot().is_empty());
3939 assert_eq!(
3940 harness
3941 .launcher
3942 .snapshot()
3943 .iter()
3944 .filter(|a| !a.is_terminal())
3945 .count(),
3946 0
3947 );
3948 }
3949
3950 // =======================================================================
3951 // D9 under concurrency: the other silent failure
3952 // =======================================================================
3953
3954 /// `e1`'s Definition of Done: *"Two policies on one host with
3955 /// `host_capacity` smaller than the sum of their `max_capacity` values never
3956 /// exceed `host_capacity` under concurrent reconciliation — asserted under
3957 /// simulated lock contention, with no duplicate runners."*
3958 ///
3959 /// The contention is simulated by [`FakeLauncher::with_yields`], which puts
3960 /// executor yield points *between* the launcher reading the attempt set and
3961 /// recording the new one. Without serialisation both tasks read a headroom
3962 /// of three and both spend it.
3963 ///
3964 /// # Watched failing before it was made to pass
3965 ///
3966 /// Granting from this lock without taking the inner mutex — leaving every
3967 /// counter and every yield point exactly as they are — fails this assertion
3968 /// with `left: 4, right: 3`: four runners on a host of three, from two
3969 /// policies each individually inside their own `max_capacity`. The control
3970 /// test below keeps that measurement standing permanently by running the
3971 /// same body against [`NoLock`].
3972 #[tokio::test(flavor = "current_thread")]
3973 async fn two_policies_reconciling_concurrently_never_exceed_host_capacity() {
3974 let lock = Arc::new(CountingLock::new());
3975 let (launches, live) =
3976 two_policies_concurrently(Arc::clone(&lock) as Arc<dyn AllocationLock>).await;
3977
3978 assert_eq!(
3979 launches, 3,
3980 "the sum across policies must never exceed host_capacity, and each policy is \
3981 individually within its own max_capacity of 3"
3982 );
3983 assert_eq!(live, 3, "and no duplicate runner survived the race");
3984 assert_eq!(
3985 lock.peak(),
3986 1,
3987 "the allocation lock had one holder at a time; without that the read of the \
3988 headroom and the creation of the runtime are not atomic"
3989 );
3990 assert!(
3991 (3..=5).contains(&lock.acquisitions()),
3992 "the lock is taken before *each* runtime, not once per pass: three runtimes \
3993 means at least three holds, and at most one further hold per policy to \
3994 discover the host filled up underneath it. It was taken {} times",
3995 lock.acquisitions()
3996 );
3997 }
3998
3999 /// The control for the test above: the same body with the lock removed.
4000 ///
4001 /// It exists so that the assertion above cannot pass vacuously. If a future
4002 /// change makes the unserialised path safe by accident — a launcher that
4003 /// records synchronously, say — this test goes red and says so, rather than
4004 /// the other one silently proving nothing.
4005 #[tokio::test(flavor = "current_thread")]
4006 async fn without_the_allocation_lock_two_policies_oversubscribe_the_host() {
4007 let (launches, _) =
4008 two_policies_concurrently(Arc::new(NoLock) as Arc<dyn AllocationLock>).await;
4009
4010 assert!(
4011 launches > 3,
4012 "with no serialisation both policies must be able to spend the same headroom; \
4013 they started {launches} runners on a host of 3. If this is ever 3, the \
4014 contention window closed and `two_policies_reconciling_concurrently_never_\
4015 exceed_host_capacity` has stopped proving anything"
4016 );
4017 }
4018
4019 /// Two policies, one host of three, each allowed three, reconciled at once.
4020 ///
4021 /// Returns `(launches, live attempts)`.
4022 async fn two_policies_concurrently(lock: Arc<dyn AllocationLock>) -> (usize, usize) {
4023 let launcher = Arc::new(FakeLauncher::new().with_yields(4));
4024 let host = host_with(3);
4025
4026 let mut left = Harness::build(
4027 host.clone(),
4028 Arc::clone(&launcher),
4029 Arc::new(FakeDemand::ready(3, &repo("acme/left"))),
4030 Arc::clone(&lock),
4031 )
4032 .reconciler;
4033 let mut right = Harness::build(
4034 host,
4035 Arc::clone(&launcher),
4036 Arc::new(FakeDemand::ready(3, &repo("acme/right"))),
4037 Arc::clone(&lock),
4038 )
4039 .reconciler;
4040
4041 let a = policy(1, "acme/left", 3);
4042 let b = policy(2, "acme/right", 3);
4043
4044 let left = tokio::spawn(async move { left.reconcile(&[a]).await });
4045 let right = tokio::spawn(async move { right.reconcile(&[b]).await });
4046 let (_, _) = (left.await.unwrap(), right.await.unwrap());
4047
4048 (launcher.launches(), launcher.live_count())
4049 }
4050
4051 // =======================================================================
4052 // D19: monitor-only
4053 // =======================================================================
4054
4055 /// `e1`'s Definition of Done: *"A `MonitorOnly` policy under maximum demand
4056 /// starts zero runners and issues no demand request."*
4057 ///
4058 /// Driven through `c4`'s real gateway fake so that "issued no demand
4059 /// request" is asserted against the thing that would have issued it, rather
4060 /// than against this module's own bookkeeping. `FakeGithub` records every
4061 /// call it is asked to make.
4062 #[tokio::test]
4063 async fn a_monitor_only_policy_under_maximum_demand_starts_nothing_and_polls_nothing() {
4064 let gateway = FakeGithub::new().with_queued_jobs(repo("acme/app"), jobs(10_000));
4065 let gateway = Arc::new(GatewayDemand::new(gateway, CancelToken::new()));
4066 let launcher = Arc::new(FakeLauncher::new());
4067 let events = Arc::new(EventLog::new());
4068
4069 let mut reconciler = Reconciler::new(
4070 host_with(10),
4071 ReconcilerPorts {
4072 demand: Arc::clone(&gateway) as Arc<dyn DemandSource>,
4073 launcher: Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
4074 lock: Arc::new(InProcessAllocationLock::new()),
4075 directory: Arc::new(FakeDirectory::default()),
4076 clock: Arc::new(FakeClock::default()),
4077 jitter: Arc::new(NoJitter),
4078 events: Arc::clone(&events) as Arc<dyn EventSink>,
4079 },
4080 );
4081
4082 let monitor = fixtures::policy()
4083 .id(PolicyId::from_u128(1))
4084 .repository("acme/app")
4085 .monitor_only()
4086 .active()
4087 .build();
4088
4089 let report = reconciler.reconcile(&[monitor]).await;
4090
4091 assert_eq!(report.started, 0);
4092 assert_eq!(launcher.launches(), 0);
4093 assert_eq!(report.monitor_only, vec![PolicyId::from_u128(1)]);
4094 assert_eq!(
4095 report.demand_requests, 0,
4096 "a monitor-only policy spends nothing from the shared hourly ceiling"
4097 );
4098 assert!(
4099 gateway.gateway().calls().is_empty(),
4100 "a monitor-only policy issued a demand request: {:?}",
4101 gateway.gateway().calls()
4102 );
4103 assert_eq!(events.count_of("monitor_only_skipped"), 1);
4104 assert_eq!(
4105 events.count_of("demand_observed"),
4106 0,
4107 "and it contributed no demand"
4108 );
4109 }
4110
4111 /// D19 says a monitor-only policy is *"skipped entirely by
4112 /// reconciliation"*, and "entirely" is the load-bearing word once two
4113 /// policies share a target.
4114 ///
4115 /// This defect was found by review rather than by the test above, which
4116 /// cannot see it: there, the monitor-only policy is the *only* policy, so
4117 /// nobody polls its target and the lookup finds nothing. Give it a
4118 /// repository an autoscale policy already polls and the lookup succeeds —
4119 /// and the monitor-only policy was then allocated for and had a demand
4120 /// observation emitted on its behalf. It still started nothing, because
4121 /// `may_start_runners` is false for it and `HostAllocator` refuses it by
4122 /// name, so no ceiling was ever at risk. It simply was not skipped.
4123 ///
4124 /// Removing the `owns_runners` guard from the allocation loop was watched
4125 /// failing this test before it was restored:
4126 /// `a monitor-only policy was allocated for: [… limiting_factor:
4127 /// MonitorOnly]`.
4128 #[tokio::test]
4129 async fn a_monitor_only_policy_sharing_a_target_is_still_skipped_entirely() {
4130 let lock = Arc::new(CountingLock::new());
4131 let launcher = Arc::new(FakeLauncher::new());
4132 let events = Arc::new(EventLog::new());
4133 let mut reconciler = Reconciler::new(
4134 host_with(4),
4135 ReconcilerPorts {
4136 demand: Arc::new(FakeDemand::ready(2, &repo("acme/app"))),
4137 launcher: Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
4138 lock: Arc::clone(&lock) as Arc<dyn AllocationLock>,
4139 directory: Arc::new(FakeDirectory::default()),
4140 clock: Arc::new(FakeClock::default()),
4141 jitter: Arc::new(NoJitter),
4142 events: Arc::clone(&events) as Arc<dyn EventSink>,
4143 },
4144 );
4145
4146 let watcher = fixtures::policy()
4147 .id(PolicyId::from_u128(2))
4148 .repository("acme/app")
4149 .monitor_only()
4150 .active()
4151 .build();
4152
4153 let report = reconciler
4154 .reconcile(&[policy(1, "acme/app", 4), watcher])
4155 .await;
4156
4157 assert_eq!(report.started, 2, "the autoscale policy is served normally");
4158 assert_eq!(report.monitor_only, vec![PolicyId::from_u128(2)]);
4159 assert_eq!(
4160 events.count_of("demand_observed"),
4161 1,
4162 "the demand observation belongs to the autoscale policy alone"
4163 );
4164 assert!(
4165 report
4166 .allocations
4167 .iter()
4168 .all(|a| a.policy_id == PolicyId::from_u128(1)),
4169 "a monitor-only policy was allocated for: {:?}",
4170 report.allocations
4171 );
4172 assert_eq!(
4173 lock.acquisitions(),
4174 2,
4175 "one hold per runtime created, and none on behalf of the monitor-only policy. \
4176 It was three before the budget was checked at the top of the loop rather than \
4177 after the re-read, which cost every policy a surplus hold to discover there \
4178 was nothing left to grant"
4179 );
4180 }
4181
4182 #[tokio::test]
4183 async fn the_monitor_only_refusal_is_asserted_on_the_mode_not_on_a_missing_ceiling() {
4184 // The specification requires this to be asserted rather than deduced
4185 // from `max_capacity` being absent. `HostAllocator` reports it by name,
4186 // and this loop reaches that arm through `owns_runners`, which is a
4187 // question about the mode.
4188 let monitor = fixtures::monitor_only_policy();
4189 assert!(!monitor.owns_runners());
4190 assert_eq!(monitor.max_capacity(), None);
4191
4192 let host = host_with(10);
4193 let attempts: Vec<RunnerAttempt> = Vec::new();
4194 let mut allocator = HostAllocator::from_attempts(&host, &attempts);
4195 let allocation = allocator.allocate(&monitor, 10_000);
4196 assert_eq!(allocation.limiting_factor, LimitingFactor::MonitorOnly);
4197 assert_eq!(allocation.to_start, 0);
4198 assert_eq!(
4199 allocator.headroom(),
4200 10,
4201 "and it consumes no headroom, so an autoscale policy on the same host is \
4202 unaffected"
4203 );
4204 }
4205
4206 // =======================================================================
4207 // The surplus runner, and busy protection
4208 // =======================================================================
4209
4210 /// `e1`'s Definition of Done: *"A surplus attempt that receives no job
4211 /// reaches a terminal state recorded as an idle exit, is cleaned, and is not
4212 /// reported as a failure."*
4213 #[tokio::test]
4214 async fn a_surplus_attempt_is_cleaned_as_an_idle_exit_and_not_as_a_failure() {
4215 let launcher = Arc::new(FakeLauncher::new().seeded(vec![
4216 concluded(1, 1, AttemptOutcome::ExitedIdleWithoutWork),
4217 concluded(
4218 2,
4219 1,
4220 AttemptOutcome::failed(FailureReason::JitRequestFailed),
4221 ),
4222 concluded(3, 1, AttemptOutcome::CompletedJob),
4223 ]));
4224 let mut harness = Harness::build(
4225 host_with(4),
4226 Arc::clone(&launcher),
4227 Arc::new(FakeDemand::ready(0, &repo("acme/app"))),
4228 Arc::new(InProcessAllocationLock::new()),
4229 );
4230
4231 let report = harness
4232 .reconciler
4233 .reconcile(&[policy(1, "acme/app", 4)])
4234 .await;
4235
4236 assert_eq!(report.cleaned, 3);
4237 assert_eq!(report.idle_exits, 1, "the surplus case, counted apart");
4238 assert_eq!(
4239 report.failures, 1,
4240 "only the failed attempt is a failure; the idle exit and the completed job are \
4241 not"
4242 );
4243 assert_eq!(launcher.cleaned().len(), 3);
4244 assert!(launcher.snapshot().is_empty());
4245
4246 let cleaned: Vec<OutcomeKind> = harness
4247 .events
4248 .events()
4249 .into_iter()
4250 .filter_map(|event| match event {
4251 LifecycleEvent::AttemptCleaned { outcome, .. } => Some(outcome),
4252 _ => None,
4253 })
4254 .collect();
4255 assert!(cleaned.contains(&OutcomeKind::IdleExit));
4256 assert!(
4257 !OutcomeKind::IdleExit.is_failure(),
4258 "an idle exit rendered as a failure sends an operator hunting a fault that does \
4259 not exist"
4260 );
4261 }
4262
4263 /// `e1`'s Definition of Done: *"A scale-down request with a busy attempt
4264 /// removes nothing and leaves the attempt `busy`."*
4265 #[tokio::test]
4266 async fn scale_down_removes_nothing_from_a_busy_attempt() {
4267 let busy = attempt_in(AttemptState::Busy, 1, 1);
4268 let launcher = Arc::new(FakeLauncher::new().seeded(vec![
4269 busy.clone(),
4270 attempt_in(AttemptState::Starting, 2, 1),
4271 concluded(3, 1, AttemptOutcome::CompletedJob),
4272 ]));
4273 let harness = Harness::build(
4274 host_with(4),
4275 Arc::clone(&launcher),
4276 Arc::new(FakeDemand::ready(0, &repo("acme/app"))),
4277 Arc::new(InProcessAllocationLock::new()),
4278 );
4279
4280 let report = harness
4281 .reconciler
4282 .scale_down(&policy(1, "acme/app", 4))
4283 .await;
4284
4285 assert_eq!(report.refused_busy, 1);
4286 assert_eq!(
4287 report.retained, 1,
4288 "the `starting` attempt is not ended either"
4289 );
4290 assert_eq!(report.removed, 1, "only the concluded attempt is reclaimed");
4291
4292 let after = launcher.snapshot();
4293 let still_busy = after
4294 .iter()
4295 .find(|a| a.id == AttemptId::from_u128(1))
4296 .expect("the busy attempt is still there");
4297 assert_eq!(
4298 still_busy.state(),
4299 AttemptState::Busy,
4300 "scale-down removed nothing from a runner that is executing a job, and left it \
4301 busy"
4302 );
4303 assert!(!launcher.cleaned().contains(&AttemptId::from_u128(1)));
4304
4305 // And the domain refuses it from the other side too, by name, so a
4306 // future caller that tried anyway would not get a generic transition
4307 // error.
4308 let mut busy = busy;
4309 assert!(matches!(
4310 busy.clean(fixtures::created_at()),
4311 Err(runner_manager_domain::attempt::AttemptError::BusyCannotBeCleaned)
4312 ));
4313 assert_eq!(harness.events.count_of("scale_down_refused"), 1);
4314 }
4315
4316 // =======================================================================
4317 // The schedule
4318 // =======================================================================
4319
4320 #[test]
4321 fn the_default_interval_is_sixty_seconds_and_the_floor_is_thirty() {
4322 assert_eq!(RefreshInterval::DEFAULT_SECS, 60);
4323 assert_eq!(RefreshInterval::MIN_SECS, 30);
4324 assert_eq!(PollSchedule::floor(), Duration::from_secs(30));
4325 assert!(
4326 RefreshInterval::from_secs(29).is_err(),
4327 "the floor is a rate-budget constraint, and a caller must not be able to write \
4328 a shorter interval at all"
4329 );
4330
4331 let mut schedule = PollSchedule::new(RefreshInterval::default());
4332 let next = schedule.next_poll(None, fixtures::created_at(), &NoJitter);
4333 assert_eq!(next.delay, Duration::from_secs(60));
4334 assert_eq!(next.pace, PollPace::Nominal);
4335
4336 let mut floored = PollSchedule::new(RefreshInterval::from_secs(30).unwrap());
4337 assert_eq!(
4338 floored
4339 .next_poll(None, fixtures::created_at(), &NoJitter)
4340 .delay,
4341 Duration::from_secs(30)
4342 );
4343 }
4344
4345 /// `e1`'s Definition of Done: *"The poll interval … increases under a
4346 /// rate-limit signal, and the increase is visible in emitted state rather
4347 /// than silent."*
4348 #[test]
4349 fn a_rate_limit_increases_the_delay_and_names_itself() {
4350 let now = fixtures::created_at();
4351 let mut schedule = PollSchedule::new(RefreshInterval::default());
4352
4353 let limited = RefreshState::RateLimited(RateLimited {
4354 kind: RateLimitKind::Secondary,
4355 retry_after: Some(Duration::from_secs(300)),
4356 remaining: None,
4357 reset_unix_secs: None,
4358 });
4359 let next = schedule.next_poll(Some(&limited), now, &NoJitter);
4360
4361 assert_eq!(next.delay, Duration::from_secs(300));
4362 assert_eq!(
4363 next.pace,
4364 PollPace::RateLimited {
4365 kind: RateLimitKind::Secondary
4366 },
4367 "the increase is reported, never hidden"
4368 );
4369 assert!(next.pace.is_throttled());
4370 assert_eq!(next.pace.as_str(), "rate_limited_secondary");
4371 }
4372
4373 /// Constraint on this task: *"Read `RefreshState::retry_delay` as an
4374 /// absolute floor, not an addend."*
4375 #[test]
4376 fn the_retry_delay_is_an_absolute_floor_and_never_an_addend() {
4377 let now = fixtures::created_at();
4378 let mut schedule = PollSchedule::new(RefreshInterval::default());
4379
4380 let limited = RefreshState::RateLimited(RateLimited {
4381 kind: RateLimitKind::Primary,
4382 retry_after: Some(Duration::from_secs(300)),
4383 remaining: Some(0),
4384 reset_unix_secs: None,
4385 });
4386
4387 // Five successive answers, each carrying the window that is *left*.
4388 // An addend would compound: 360, 660, 960 … and look like a hang.
4389 for _ in 0..5 {
4390 let next = schedule.next_poll(Some(&limited), now, &NoJitter);
4391 assert_eq!(
4392 next.delay,
4393 Duration::from_secs(300),
4394 "the delay is `max(interval, retry_delay)`; `interval + retry_delay` would \
4395 have compounded on every successive retry"
4396 );
4397 }
4398
4399 // And when GitHub asks for less than the interval, the interval wins:
4400 // the floor is never crossed to catch up.
4401 let brief = RefreshState::RateLimited(RateLimited {
4402 kind: RateLimitKind::Secondary,
4403 retry_after: Some(Duration::from_secs(5)),
4404 remaining: None,
4405 reset_unix_secs: None,
4406 });
4407 let next = schedule.next_poll(Some(&brief), now, &NoJitter);
4408 assert_eq!(
4409 next.delay,
4410 Duration::from_secs(60),
4411 "a short `retry-after` may not drop the loop below its own interval"
4412 );
4413 assert!(next.delay >= PollSchedule::floor());
4414 }
4415
4416 #[test]
4417 fn no_branch_of_the_schedule_can_go_below_the_thirty_second_floor() {
4418 let now = fixtures::created_at();
4419 let states = [
4420 None,
4421 Some(RefreshState::Offline),
4422 Some(RefreshState::RateLimited(RateLimited {
4423 kind: RateLimitKind::Secondary,
4424 retry_after: Some(Duration::from_secs(1)),
4425 remaining: None,
4426 reset_unix_secs: None,
4427 })),
4428 Some(RefreshState::LockedOut {
4429 retry_after: Duration::from_secs(1),
4430 }),
4431 Some(RefreshState::Unauthorized),
4432 Some(RefreshState::Forbidden { message: None }),
4433 Some(RefreshState::Failed {
4434 status: Some(500),
4435 message: "server error".into(),
4436 }),
4437 Some(RefreshState::Cancelled),
4438 ];
4439
4440 for state in &states {
4441 let mut schedule = PollSchedule::new(RefreshInterval::from_secs(30).unwrap());
4442 let next = schedule.next_poll(state.as_ref(), now, &NoJitter);
4443 assert!(
4444 next.delay >= PollSchedule::floor(),
4445 "{state:?} scheduled a poll {}ms away, under the 30-second floor",
4446 next.delay.as_millis()
4447 );
4448 }
4449 }
4450
4451 #[test]
4452 fn an_offline_run_backs_off_with_jitter_and_a_recovery_resets_it() {
4453 let now = fixtures::created_at();
4454 let mut schedule = PollSchedule::new(RefreshInterval::default());
4455
4456 // Doubling, from the nominal interval.
4457 let mut previous = Duration::ZERO;
4458 for consecutive in 1..=6_u32 {
4459 let next = schedule.next_poll(Some(&RefreshState::Offline), now, &NoJitter);
4460 assert_eq!(next.pace, PollPace::Offline { consecutive });
4461 assert!(
4462 next.delay >= previous,
4463 "the back-off must not shrink while the outage continues"
4464 );
4465 assert!(next.delay >= Duration::from_secs(60));
4466 previous = next.delay;
4467 }
4468 assert!(previous <= MAX_OFFLINE_BACKOFF, "and it is capped");
4469
4470 // Jitter widens the delay rather than narrowing it, so a fleet of
4471 // agents does not retry in lockstep.
4472 let mut jittered = PollSchedule::new(RefreshInterval::default());
4473 let none = jittered.next_poll(Some(&RefreshState::Offline), now, &NoJitter);
4474 let mut jittered = PollSchedule::new(RefreshInterval::default());
4475 let full = jittered.next_poll(Some(&RefreshState::Offline), now, &FixedJitter(0.999));
4476 assert!(full.delay > none.delay);
4477 assert!(full.delay <= none.delay.mul_f64(1.0 + JITTER_RATIO));
4478
4479 // Recovery resets the run with no bookkeeping of its own.
4480 assert_eq!(schedule.consecutive_offline(), 6);
4481 let recovered = schedule.next_poll(None, now, &NoJitter);
4482 assert_eq!(recovered.pace, PollPace::Nominal);
4483 assert_eq!(recovered.delay, Duration::from_secs(60));
4484 assert_eq!(schedule.consecutive_offline(), 0);
4485 }
4486
4487 #[test]
4488 fn the_offline_state_states_the_twenty_four_hour_bound() {
4489 assert_eq!(
4490 GITHUB_CANCELS_QUEUED_JOBS_AFTER,
4491 Duration::from_secs(24 * 60 * 60)
4492 );
4493
4494 let brief = OfflineState::new(1, Duration::from_secs(120));
4495 let rendered = brief.to_string();
4496 assert!(rendered.contains("24 hours"), "{rendered}");
4497 assert!(rendered.contains("Retrying in 120s"), "{rendered}");
4498 assert!(!brief.has_outlasted_the_queue());
4499
4500 let long = brief.since(GITHUB_CANCELS_QUEUED_JOBS_AFTER + Duration::from_secs(1));
4501 assert!(long.has_outlasted_the_queue());
4502 assert!(
4503 long.to_string().contains("queued work has been lost"),
4504 "{long}"
4505 );
4506
4507 // "We cannot tell" is not "not yet".
4508 assert!(!OfflineState::new(9, Duration::from_secs(60)).has_outlasted_the_queue());
4509 }
4510
4511 // =======================================================================
4512 // Offline, end to end
4513 // =======================================================================
4514
4515 /// `e1`'s Definition of Done: *"An unreachable GitHub yields `offline`, zero
4516 /// new runners, retained existing processes, and jittered backoff; recovery
4517 /// resumes polling and does not double-count a job that was already being
4518 /// served."*
4519 #[tokio::test]
4520 async fn an_unreachable_github_starts_nothing_retains_everything_and_backs_off() {
4521 let live = vec![
4522 attempt_in(AttemptState::Busy, 1, 1),
4523 attempt_in(AttemptState::Starting, 2, 1),
4524 ];
4525 let launcher = Arc::new(FakeLauncher::new().seeded(live.clone()));
4526 let demand = Arc::new(FakeDemand::failing(RefreshState::Offline));
4527 let mut harness = Harness::build(
4528 host_with(8),
4529 Arc::clone(&launcher),
4530 Arc::clone(&demand),
4531 Arc::new(InProcessAllocationLock::new()),
4532 );
4533 let policy = policy(1, "acme/app", 8);
4534
4535 let report = harness
4536 .reconciler
4537 .reconcile(std::slice::from_ref(&policy))
4538 .await;
4539
4540 assert!(report.is_offline());
4541 assert_eq!(report.started, 0, "no new runner during an outage");
4542 assert_eq!(launcher.launches(), 0);
4543 assert_eq!(
4544 launcher.snapshot(),
4545 live,
4546 "existing runner processes are retained, untouched"
4547 );
4548 assert_eq!(report.unreadable, vec![PolicyId::from_u128(1)]);
4549 assert_eq!(report.next_poll.pace, PollPace::Offline { consecutive: 1 });
4550 assert!(report.next_poll.delay >= Duration::from_secs(60));
4551 let offline = report.offline_state().expect("an offline state to display");
4552 assert!(offline.to_string().contains("24 hours"));
4553
4554 // Recovery: the same job is still queued, and one runner is already
4555 // serving it. Demand is recomputed from the current queued set rather
4556 // than accumulated, so the reconnect starts nothing new.
4557 demand.set(PollOutcome::Ready(QueuedDemand::of(
4558 repo("acme/app"),
4559 jobs(2),
4560 )));
4561 let recovered = harness.reconciler.reconcile(&[policy]).await;
4562
4563 assert!(!recovered.is_offline());
4564 assert_eq!(recovered.next_poll.pace, PollPace::Nominal);
4565 assert_eq!(
4566 recovered.started, 0,
4567 "two queued runs, two attempts already in flight: a reconnect cannot \
4568 double-count work"
4569 );
4570 assert_eq!(recovered.allocations[0].active_owned, 2);
4571 assert_eq!(launcher.live_count(), 2);
4572 }
4573
4574 /// One unreachable target must not idle a whole host.
4575 ///
4576 /// The failure that decides the *schedule* is the most severe across every
4577 /// target polled — backing the whole loop off during an outage is the safe
4578 /// error, and `f3` runs one reconciler per target anyway, so in production
4579 /// the two are usually the same thing. What must not follow from that is
4580 /// refusing to serve a policy whose own target answered perfectly well, and
4581 /// the two are easy to conflate because the offline reading is sitting in
4582 /// the same map.
4583 #[tokio::test]
4584 async fn one_offline_target_does_not_stop_a_reachable_one() {
4585 let mut harness = Harness::simple(8, 0, "acme/app");
4586 harness.demand.set_for(
4587 &ScaleTarget::repository("acme/app").unwrap(),
4588 PollOutcome::Ready(QueuedDemand::of(repo("acme/app"), jobs(2))),
4589 );
4590 harness.demand.set_for(
4591 &ScaleTarget::repository("acme/broken").unwrap(),
4592 PollOutcome::Failed(RefreshState::Offline),
4593 );
4594
4595 let report = harness
4596 .reconciler
4597 .reconcile(&[policy(1, "acme/app", 4), policy(2, "acme/broken", 4)])
4598 .await;
4599
4600 assert_eq!(
4601 report.started, 2,
4602 "the reachable target was served; an unreachable sibling repository must not \
4603 idle the host"
4604 );
4605 assert_eq!(report.unreadable, vec![PolicyId::from_u128(2)]);
4606 assert_eq!(harness.demand.polls().len(), 2, "both targets were polled");
4607
4608 // And the schedule takes the worse of the two.
4609 assert!(report.is_offline());
4610 assert_eq!(report.next_poll.pace, PollPace::Offline { consecutive: 1 });
4611 }
4612
4613 /// The 24-hour bound has to be reachable in production, not only in a unit
4614 /// test of [`OfflineState`].
4615 ///
4616 /// This was a real gap: the reconciler built its offline state from the
4617 /// back-off count alone, so `offline_for` was always `None` and
4618 /// [`OfflineState::has_outlasted_the_queue`] could never be true outside a
4619 /// test that constructed the value by hand. An operator whose agent had been
4620 /// offline for two days would have been told that an outage longer than 24
4621 /// hours *would* lose queued work, in the future tense, having already lost
4622 /// it.
4623 ///
4624 /// The elapsed time is measured from the first poll of the run rather than
4625 /// derived from the interval, because the back-off doubles and the two
4626 /// diverge immediately.
4627 #[tokio::test]
4628 async fn a_day_long_outage_says_that_queued_work_has_already_been_lost() {
4629 let clock = Arc::new(FakeClock::default());
4630 let launcher = Arc::new(FakeLauncher::new());
4631 let demand = Arc::new(FakeDemand::failing(RefreshState::Offline));
4632 let mut reconciler = Reconciler::new(
4633 host_with(4),
4634 ReconcilerPorts {
4635 demand: Arc::clone(&demand) as Arc<dyn DemandSource>,
4636 launcher: Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
4637 lock: Arc::new(InProcessAllocationLock::new()),
4638 directory: Arc::new(FakeDirectory::default()),
4639 clock: Arc::clone(&clock) as Arc<dyn Clock>,
4640 jitter: Arc::new(NoJitter),
4641 events: Arc::new(NoEvents),
4642 },
4643 );
4644 let policy = policy(1, "acme/app", 4);
4645
4646 // The outage begins.
4647 let first = reconciler.reconcile(std::slice::from_ref(&policy)).await;
4648 let state = first.offline_state().expect("an offline state");
4649 assert!(!state.has_outlasted_the_queue());
4650 assert!(
4651 state
4652 .to_string()
4653 .contains("an outage longer than that loses"),
4654 "{state}"
4655 );
4656
4657 // A day and a minute later, still unreachable.
4658 clock.advance_secs(24 * 60 * 60 + 60);
4659 let later = reconciler.reconcile(std::slice::from_ref(&policy)).await;
4660 let state = later.offline_state().expect("an offline state");
4661 assert!(state.has_outlasted_the_queue());
4662 assert!(
4663 state.to_string().contains("queued work has been lost"),
4664 "{state}"
4665 );
4666 assert_eq!(launcher.launches(), 0, "and still nothing was started");
4667
4668 // Recovery closes the run, so a *later* outage measures from itself
4669 // rather than from the first one.
4670 demand.set(PollOutcome::Ready(QueuedDemand::of(
4671 repo("acme/app"),
4672 jobs(0),
4673 )));
4674 let recovered = reconciler.reconcile(std::slice::from_ref(&policy)).await;
4675 assert!(recovered.offline_state().is_none());
4676 assert_eq!(reconciler.schedule().offline_for(clock.now()), None);
4677
4678 demand.set(PollOutcome::Failed(RefreshState::Offline));
4679 let again = reconciler.reconcile(std::slice::from_ref(&policy)).await;
4680 assert!(
4681 !again
4682 .offline_state()
4683 .expect("an offline state")
4684 .has_outlasted_the_queue(),
4685 "a new outage must not inherit the age of the one before it"
4686 );
4687 }
4688
4689 /// Finding 5: the adapter, not the lock underneath it.
4690 ///
4691 /// `d1` covers `LockKind::Allocation` including a contended `acquire_at`
4692 /// with a wait. What that does not reach is this adapter: the
4693 /// `spawn_blocking` wrapper, the collapse of both a refused lock and a
4694 /// panicked blocking task into `AllocationLockBusy`, and — the one that
4695 /// would be silent — whether [`AllocationGuard`] really holds the
4696 /// `HostLock`, since dropping it is the only release there is. A guard that
4697 /// dropped the lock on the way out would make every acquisition succeed and
4698 /// the ceiling would hold by luck.
4699 ///
4700 /// The original disclosure said this needed a real filesystem and was
4701 /// therefore expensive. `AppPaths::rooted_at` plus `tempfile` — already a
4702 /// non-dev dependency of this crate — makes it about fifteen lines, so the
4703 /// reason was weaker than stated.
4704 #[tokio::test]
4705 async fn the_file_allocation_lock_excludes_a_second_holder_and_releases_on_drop() {
4706 let root = tempfile::tempdir().expect("a temporary directory");
4707 let paths = Arc::new(runner_manager_platform::paths::AppPaths::rooted_at(
4708 root.path(),
4709 ));
4710 let lock = FileAllocationLock::new(paths).with_wait(Duration::from_millis(50));
4711
4712 let held = lock.acquire().await.expect("an uncontended lock is free");
4713 assert!(
4714 matches!(lock.acquire().await, Err(AllocationLockBusy)),
4715 "a second holder was admitted; on Unix the lock is per open file description \
4716 and on Windows the share mode denies write, so this must be refused even \
4717 from inside the same process"
4718 );
4719
4720 drop(held);
4721 let regained = lock.acquire().await;
4722 assert!(
4723 regained.is_ok(),
4724 "dropping the guard is the only release there is, so a guard that does not \
4725 hold the `HostLock` leaves it held forever"
4726 );
4727 }
4728
4729 #[test]
4730 fn tee_events_reaches_both_sinks() {
4731 // `f3` wires the log sink and `g2`'s buffer at once, and an event that
4732 // reached only one of them would be an activity view missing lines the
4733 // log file has, or the reverse.
4734 let left = Arc::new(EventLog::new());
4735 let right = Arc::new(EventLog::new());
4736 let tee = TeeEvents(
4737 Arc::clone(&left) as Arc<dyn EventSink>,
4738 Arc::clone(&right) as Arc<dyn EventSink>,
4739 );
4740
4741 tee.emit(LifecycleEvent::MonitorOnlySkipped {
4742 policy: PolicyId::from_u128(1),
4743 });
4744
4745 assert_eq!(left.count_of("monitor_only_skipped"), 1);
4746 assert_eq!(right.count_of("monitor_only_skipped"), 1);
4747 }
4748
4749 // =======================================================================
4750 // Budget: the repository list, and the per-target poll
4751 // =======================================================================
4752
4753 #[tokio::test]
4754 async fn the_repository_list_refreshes_far_more_slowly_than_the_demand_poll() {
4755 let clock = Arc::new(FakeClock::default());
4756 let directory = Arc::new(FakeDirectory::of(vec![repo("acme/one"), repo("acme/two")]));
4757 let cache = RepositoryCache::new(
4758 Arc::clone(&directory) as Arc<dyn RepositoryDirectory>,
4759 Arc::clone(&clock) as Arc<dyn Clock>,
4760 RefreshInterval::default(),
4761 );
4762 let target = ScaleTarget::organization("acme").unwrap();
4763
4764 assert_eq!(
4765 cache.ttl(),
4766 Duration::from_secs(60 * u64::from(REPOSITORY_LIST_REFRESH_MULTIPLE))
4767 );
4768
4769 // Every poll inside the window reuses the list.
4770 for _ in 0..REPOSITORY_LIST_REFRESH_MULTIPLE {
4771 let scope = cache.scope_for(&target).await.unwrap();
4772 assert_eq!(scope.repositories().len(), 2);
4773 clock.advance_secs(60);
4774 }
4775 assert_eq!(
4776 directory.calls(),
4777 1,
4778 "re-listing an organization at demand-poll frequency is what exhausts the \
4779 shared request budget"
4780 );
4781 assert_eq!(cache.lookups(), 1);
4782
4783 // Past it, exactly one more.
4784 cache.scope_for(&target).await.unwrap();
4785 assert_eq!(directory.calls(), 2);
4786 }
4787
4788 #[tokio::test]
4789 async fn a_repository_target_never_consults_the_directory() {
4790 let directory = Arc::new(FakeDirectory::of(vec![repo("acme/other")]));
4791 let cache = RepositoryCache::new(
4792 Arc::clone(&directory) as Arc<dyn RepositoryDirectory>,
4793 Arc::new(FakeClock::default()) as Arc<dyn Clock>,
4794 RefreshInterval::default(),
4795 );
4796 let target = ScaleTarget::repository("acme/app").unwrap();
4797
4798 let scope = cache.scope_for(&target).await.unwrap();
4799 assert_eq!(scope.repositories(), &[repo("acme/app")]);
4800 assert_eq!(directory.calls(), 0);
4801 }
4802
4803 #[tokio::test]
4804 async fn two_policies_on_one_target_cost_one_demand_poll_not_two() {
4805 // `04-subsystem-contracts.md` prices a *target*. A loop that spent per
4806 // policy would exceed the projection `f2` admitted the configuration
4807 // against, silently.
4808 let mut harness = Harness::simple(8, 4, "o/r");
4809 harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
4810 repo("o/r"),
4811 [
4812 fixtures::queued_job(&[HOST_LABEL]),
4813 fixtures::queued_job(&[HOST_LABEL]),
4814 fixtures::queued_job(&["rm-home-win-x64-py-isolated"]),
4815 fixtures::queued_job(&["rm-home-win-x64-py-isolated"]),
4816 ],
4817 )));
4818 let mut isolated = fixtures::named_policy("py-isolated", PolicyId::from_u128(2));
4819 isolated.activate().unwrap();
4820 let report = harness
4821 .reconciler
4822 .reconcile(&[policy(1, "o/r", 2), isolated])
4823 .await;
4824
4825 assert_eq!(harness.demand.polls().len(), 1);
4826 assert_eq!(report.targets_read, 1, "profiles share one target reading");
4827 assert_eq!(
4828 report.demand_requests,
4829 runner_manager_github::demand::DEMAND_REQUESTS_PER_REPOSITORY_PER_POLL,
4830 "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"
4831 );
4832 assert_eq!(
4833 report.started, 4,
4834 "native and isolated profiles each get two"
4835 );
4836 }
4837
4838 #[tokio::test]
4839 async fn subset_labels_without_selector_scale_only_the_compatible_local_profile() {
4840 use runner_manager_domain::model::{Arch, HostLabel, Os};
4841 use runner_manager_domain::policy::RoutingLabels;
4842 let mut harness = Harness::build(
4843 fixtures::host()
4844 .os(Os::MacOs)
4845 .architecture(Arch::Arm64)
4846 .capacity(10)
4847 .build(),
4848 Arc::new(FakeLauncher::new()),
4849 Arc::new(FakeDemand::ready(0, &repo("o/r"))),
4850 Arc::new(InProcessAllocationLock::new()),
4851 );
4852 harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
4853 repo("o/r"),
4854 [
4855 fixtures::queued_job(&["self-hosted", "macOS", "ARM64"]),
4856 fixtures::queued_job(&["SELF-HOSTED", "MACOS", "arm64"]),
4857 fixtures::queued_job(&["self-hosted", "macOS", "X64"]),
4858 fixtures::queued_job(&["self-hosted", "Windows", "ARM64"]),
4859 fixtures::queued_job(&["self-hosted", "macOS", "ARM64", "gpu"]),
4860 fixtures::queued_job(&["rm-other-osx-arm64", "macOS", "ARM64"]),
4861 ],
4862 )));
4863 let mut local = fixtures::policy()
4864 .id(PolicyId::from_u128(1))
4865 .repository("o/r")
4866 .autoscale("macmini", 10)
4867 .routing_labels(RoutingLabels::derive(
4868 &HostLabel::new("macmini").unwrap(),
4869 Os::MacOs,
4870 Arch::Arm64,
4871 ))
4872 .active()
4873 .build();
4874 for label in ["self-hosted", "macos", "arm64", "extra"] {
4875 local.add_routing_label(fixtures::label(label)).unwrap();
4876 }
4877 let mut foreign = local.to_persisted();
4878 foreign.id = PolicyId::from_u128(2);
4879 foreign.host_id = HostId::from_u128(0xdead);
4880 let foreign = ScalePolicy::from_persisted(foreign).unwrap();
4881 let report = harness.reconciler.reconcile(&[local, foreign]).await;
4882 assert_eq!(report.started, 2);
4883 assert_eq!(report.targets_read, 1);
4884 assert!(harness.events.events().iter().any(|event| matches!(
4885 event,
4886 LifecycleEvent::DemandObserved {
4887 demand: 2,
4888 not_matched: 4,
4889 ..
4890 }
4891 )));
4892 assert!(
4893 !harness
4894 .events
4895 .events()
4896 .iter()
4897 .any(|event| matches!(event, LifecycleEvent::RoutingRefused { .. }))
4898 );
4899 }
4900
4901 #[tokio::test]
4902 async fn isolated_overlap_never_falls_back_to_native_in_either_state_or_order() {
4903 use runner_manager_domain::execution::{
4904 Backend, ExecutionPolicy, ImageReference, ResourceLimits,
4905 };
4906 let mut native = policy(1, "o/r", 2);
4907 native
4908 .add_routing_label(fixtures::label("self-hosted"))
4909 .unwrap();
4910 let mut isolated = fixtures::named_policy("py-isolated", PolicyId::from_u128(2));
4911 isolated
4912 .add_routing_label(fixtures::label("self-hosted"))
4913 .unwrap();
4914 isolated
4915 .set_execution_policy(ExecutionPolicy::Isolated {
4916 backend: Backend::Oci,
4917 image: ImageReference::new(format!(
4918 "registry.example/runner@sha256:{}",
4919 "a".repeat(64)
4920 ))
4921 .unwrap(),
4922 resources: ResourceLimits {
4923 cpu_millis: 1000,
4924 memory_mib: 512,
4925 disk_mib: 1024,
4926 },
4927 })
4928 .unwrap();
4929 assert!(!isolated.may_start_runners());
4930 let mut active_isolated = isolated.clone();
4931 active_isolated.activate().unwrap();
4932 for profiles in [
4933 [native.clone(), isolated.clone()],
4934 [isolated.clone(), native.clone()],
4935 [native.clone(), active_isolated.clone()],
4936 [active_isolated, native.clone()],
4937 ] {
4938 let mut harness = Harness::simple(4, 0, "o/r");
4939 harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
4940 repo("o/r"),
4941 [fixtures::queued_job(&["SELF-HOSTED"])],
4942 )));
4943 let report = harness.reconciler.reconcile(&profiles).await;
4944 assert_eq!(report.started, 0);
4945 assert!(harness.events.events().iter().any(|event| matches!(
4946 event,
4947 LifecycleEvent::RoutingRefused {
4948 reason: RoutingRefusal::OverlappingProfiles,
4949 count: 1,
4950 ..
4951 }
4952 )));
4953 }
4954 // A job explicitly requiring the unavailable isolated selector cannot
4955 // be served by the native sibling either.
4956 let mut harness = Harness::simple(4, 0, "o/r");
4957 harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
4958 repo("o/r"),
4959 [fixtures::queued_job(&["rm-home-win-x64-py-isolated"])],
4960 )));
4961 assert_eq!(
4962 harness
4963 .reconciler
4964 .reconcile(&[native, isolated])
4965 .await
4966 .started,
4967 0
4968 );
4969 }
4970
4971 #[tokio::test]
4972 async fn shared_optional_labels_do_not_duplicate_native_and_isolated_demand() {
4973 let mut harness = Harness::simple(2, 0, "o/r");
4974 harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
4975 repo("o/r"),
4976 [
4977 fixtures::queued_job(&[HOST_LABEL, "gpu"]),
4978 fixtures::queued_job(&["rm-home-win-x64-py-isolated", "gpu"]),
4979 fixtures::queued_job(&["gpu"]),
4980 fixtures::unresolvable_job(),
4981 ],
4982 )));
4983 let mut native = policy(1, "o/r", 2);
4984 native.add_routing_label(fixtures::label("gpu")).unwrap();
4985 let mut isolated = fixtures::named_policy("py-isolated", PolicyId::from_u128(2));
4986 isolated.add_routing_label(fixtures::label("gpu")).unwrap();
4987 isolated.activate().unwrap();
4988
4989 let report = harness.reconciler.reconcile(&[native, isolated]).await;
4990 assert_eq!(harness.demand.polls().len(), 1);
4991 assert_eq!(report.targets_read, 1);
4992 assert_eq!(report.started, 2);
4993 assert_eq!(report.allocations.len(), 2);
4994 assert!(
4995 report
4996 .allocations
4997 .iter()
4998 .all(|allocation| allocation.to_start == 1)
4999 );
5000 assert!(harness.events.events().iter().any(|event| matches!(
5001 event,
5002 LifecycleEvent::RoutingRefused {
5003 reason: RoutingRefusal::OverlappingProfiles,
5004 count: 1,
5005 ..
5006 }
5007 )));
5008 assert_eq!(
5009 harness
5010 .events
5011 .events()
5012 .iter()
5013 .filter(|event| matches!(
5014 event,
5015 LifecycleEvent::DemandObserved {
5016 unresolvable: 1,
5017 ..
5018 }
5019 ))
5020 .count(),
5021 2,
5022 "an expression stays unresolvable for both profiles"
5023 );
5024 }
5025
5026 #[tokio::test]
5027 async fn missing_required_selector_labels_do_not_match_and_overlaps_show_remedies() {
5028 let mut harness = Harness::simple(4, 0, "o/r");
5029 harness.demand.set(PollOutcome::Ready(QueuedDemand::of(
5030 repo("o/r"),
5031 [fixtures::queued_job(&[
5032 HOST_LABEL,
5033 "rm-home-win-x64-py-isolated",
5034 ])],
5035 )));
5036 let mut isolated = fixtures::named_policy("py-isolated", PolicyId::from_u128(2));
5037 isolated.activate().unwrap();
5038 let report = harness
5039 .reconciler
5040 .reconcile(&[policy(1, "o/r", 2), isolated])
5041 .await;
5042 assert_eq!(report.started, 0);
5043 assert_eq!(
5044 harness
5045 .events
5046 .events()
5047 .iter()
5048 .filter(|event| matches!(
5049 event,
5050 LifecycleEvent::DemandObserved {
5051 demand: 0,
5052 not_matched: 1,
5053 ..
5054 }
5055 ))
5056 .count(),
5057 2
5058 );
5059
5060 // Domain constructors permit extra selector-shaped labels. Routing
5061 // depends on all required labels and unique ownership, not their shape.
5062 // The store separately rejects sibling selector reuse on write/load.
5063 let mut superset = policy(1, "o/r", 2);
5064 assert!(
5065 superset
5066 .add_routing_label(fixtures::label("rm-home-win-x64-py-isolated"))
5067 .unwrap()
5068 );
5069 let mut sibling = fixtures::named_policy("py-isolated", PolicyId::from_u128(2));
5070 sibling.activate().unwrap();
5071 let mut unique = Harness::simple(4, 0, "o/r");
5072 unique.demand.set(PollOutcome::Ready(QueuedDemand::of(
5073 repo("o/r"),
5074 [fixtures::queued_job(&[
5075 HOST_LABEL,
5076 "rm-home-win-x64-py-isolated",
5077 ])],
5078 )));
5079 let report = unique.reconciler.reconcile(&[superset, sibling]).await;
5080 assert_eq!(report.started, 1);
5081 assert_eq!(
5082 report
5083 .allocations
5084 .iter()
5085 .find(|allocation| allocation.to_start == 1)
5086 .unwrap()
5087 .policy_id,
5088 PolicyId::from_u128(1)
5089 );
5090 assert!(
5091 !unique
5092 .events
5093 .events()
5094 .iter()
5095 .any(|event| matches!(event, LifecycleEvent::RoutingRefused { .. }))
5096 );
5097
5098 let mut overlap = Harness::simple(4, 1, "o/r");
5099 let report = overlap
5100 .reconciler
5101 .reconcile(&[policy(1, "o/r", 2), policy(2, "o/r", 2)])
5102 .await;
5103 assert_eq!(report.started, 0);
5104 assert!(overlap.events.events().iter().any(|event| matches!(
5105 event,
5106 LifecycleEvent::RoutingRefused {
5107 reason: RoutingRefusal::OverlappingProfiles,
5108 count: 1,
5109 ..
5110 }
5111 )));
5112 assert!(
5113 RoutingRefusal::OverlappingProfiles
5114 .to_string()
5115 .contains("repair")
5116 );
5117 }
5118
5119 // =======================================================================
5120 // Failure paths
5121 // =======================================================================
5122
5123 #[tokio::test]
5124 async fn a_failed_launch_stops_the_run_and_is_reported_without_free_text() {
5125 let launcher = Arc::new(FakeLauncher::new());
5126 launcher.fail_next(FailureReason::Other("token ghp_0123456789abcdef".into()));
5127 let mut harness = Harness::build(
5128 host_with(4),
5129 Arc::clone(&launcher),
5130 Arc::new(FakeDemand::ready(3, &repo("acme/app"))),
5131 Arc::new(InProcessAllocationLock::new()),
5132 );
5133
5134 let report = harness
5135 .reconciler
5136 .reconcile(&[policy(1, "acme/app", 4)])
5137 .await;
5138 assert_eq!(report.started, 0);
5139 assert_eq!(report.allocations[0].to_start, 3, "the decision stands");
5140
5141 let failures: Vec<&'static str> = harness
5142 .events
5143 .events()
5144 .into_iter()
5145 .filter_map(|event| match event {
5146 LifecycleEvent::RunnerStartFailed { reason, .. } => Some(reason),
5147 _ => None,
5148 })
5149 .collect();
5150 assert_eq!(failures, vec!["other"]);
5151 assert!(
5152 !failures[0].contains("ghp_"),
5153 "an event carried a `FailureReason::Other` detail verbatim"
5154 );
5155 }
5156
5157 #[tokio::test]
5158 async fn a_held_allocation_lock_starts_nothing_and_says_so() {
5159 let launcher = Arc::new(FakeLauncher::new());
5160 let mut harness = Harness::build(
5161 host_with(4),
5162 Arc::clone(&launcher),
5163 Arc::new(FakeDemand::ready(3, &repo("acme/app"))),
5164 Arc::new(HeldLock),
5165 );
5166
5167 let report = harness
5168 .reconciler
5169 .reconcile(&[policy(1, "acme/app", 4)])
5170 .await;
5171 assert_eq!(report.started, 0);
5172 assert_eq!(
5173 report.deferred, 3,
5174 "three runners were granted and none was created; `deferred` counts grants, \
5175 not policies -- it reported `1` when a policy that launched two of five and \
5176 then lost the lock had left three unstarted"
5177 );
5178 assert_eq!(launcher.launches(), 0);
5179 assert_eq!(harness.events.count_of("allocation_deferred"), 1);
5180 assert!(
5181 harness
5182 .events
5183 .events()
5184 .iter()
5185 .any(|event| matches!(event, LifecycleEvent::AllocationDeferred { count: 3, .. })),
5186 "the event carries the same number the report does"
5187 );
5188 assert_eq!(
5189 report.allocations.len(),
5190 1,
5191 "the intent is still reported, so an operator staring at a queue sees why \
5192 nothing started"
5193 );
5194 }
5195
5196 #[tokio::test]
5197 async fn a_foreign_or_draining_policy_is_reported_by_name_and_polls_nothing() {
5198 let mut harness = Harness::simple(8, 5, "acme/app");
5199
5200 let foreign = fixtures::policy()
5201 .id(PolicyId::from_u128(1))
5202 .repository("acme/app")
5203 .host(HostId::from_u128(0xdead))
5204 .autoscale("office", 4)
5205 .active()
5206 .build();
5207 let mut draining = policy(2, "acme/app", 4);
5208 draining.request_disable().unwrap();
5209
5210 let report = harness.reconciler.reconcile(&[foreign, draining]).await;
5211
5212 assert_eq!(report.started, 0);
5213 assert_eq!(
5214 harness.demand.polls().len(),
5215 0,
5216 "neither can act on an answer"
5217 );
5218 let factors: Vec<LimitingFactor> = report
5219 .allocations
5220 .iter()
5221 .map(|a| a.limiting_factor)
5222 .collect();
5223 assert!(factors.contains(&LimitingFactor::ForeignHost));
5224 assert!(factors.contains(&LimitingFactor::NotReconciling));
5225 }
5226
5227 #[tokio::test]
5228 async fn an_unreadable_repository_list_makes_the_target_unreadable_not_empty() {
5229 // Polling a scope nobody chose would report a demand number for the
5230 // wrong set of repositories, which is worse than reporting nothing.
5231 #[derive(Debug)]
5232 struct BrokenDirectory;
5233
5234 #[async_trait::async_trait]
5235 impl RepositoryDirectory for BrokenDirectory {
5236 async fn repositories(&self, _org: &Org) -> Result<Vec<OwnerRepo>, InventoryError> {
5237 Err(InventoryError::Cancelled)
5238 }
5239 }
5240
5241 let launcher = Arc::new(FakeLauncher::new());
5242 let events = Arc::new(EventLog::new());
5243 let mut reconciler = Reconciler::new(
5244 host_with(4),
5245 ReconcilerPorts {
5246 demand: Arc::new(FakeDemand::default()),
5247 launcher: Arc::clone(&launcher) as Arc<dyn RunnerLauncher>,
5248 lock: Arc::new(InProcessAllocationLock::new()),
5249 directory: Arc::new(BrokenDirectory),
5250 clock: Arc::new(FakeClock::default()),
5251 jitter: Arc::new(NoJitter),
5252 events: Arc::clone(&events) as Arc<dyn EventSink>,
5253 },
5254 );
5255
5256 let org_policy = fixtures::policy()
5257 .id(PolicyId::from_u128(1))
5258 .organization("acme")
5259 .autoscale("home", 4)
5260 .active()
5261 .build();
5262
5263 let report = reconciler.reconcile(&[org_policy]).await;
5264 assert_eq!(report.started, 0);
5265 assert_eq!(report.unreadable, vec![PolicyId::from_u128(1)]);
5266 assert_eq!(events.count_of("target_unreadable"), 1);
5267 }
5268
5269 // =======================================================================
5270 // What the events may carry
5271 // =======================================================================
5272
5273 /// One value of every [`LifecycleEvent`] variant.
5274 ///
5275 /// Hand-written, and what keeps it honest is the wildcard-free `match` in
5276 /// [`LifecycleEvent::name`]: adding a variant stops that compiling and puts
5277 /// the author here. The same residual `b1` records for `FailureReason::ALL`
5278 /// applies — an author who writes the `name` arm and forgets this list gets
5279 /// a green suite with the variant unscanned.
5280 fn every_event() -> Vec<LifecycleEvent> {
5281 let policy = PolicyId::from_u128(0xabcd_ef01);
5282 let attempt = AttemptId::from_u128(0x1234_5678);
5283 vec![
5284 LifecycleEvent::DemandObserved {
5285 policy,
5286 demand: u32::MAX,
5287 not_matched: u32::MAX,
5288 unresolvable: u32::MAX,
5289 complete: false,
5290 },
5291 LifecycleEvent::TargetUnreadable {
5292 policy,
5293 reason: unreadable_reason(&RefreshState::Failed {
5294 status: Some(500),
5295 message: "Authorization: Bearer ghp_0123456789abcdefghijklmnopqrstuvwxyz"
5296 .into(),
5297 }),
5298 },
5299 LifecycleEvent::Allocated {
5300 policy,
5301 demand: u32::MAX,
5302 desired: u16::MAX,
5303 active_owned: 7,
5304 headroom: 9,
5305 to_start: 2,
5306 limiting: LimitingFactor::HostCapacity,
5307 },
5308 LifecycleEvent::MonitorOnlySkipped { policy },
5309 LifecycleEvent::RunnerStarted { policy, attempt },
5310 LifecycleEvent::RunnerStartFailed {
5311 policy,
5312 reason: failure_reason_kind(&FailureReason::Other(
5313 "Authorization: Bearer ghp_0123456789abcdefghijklmnopqrstuvwxyz".into(),
5314 )),
5315 },
5316 LifecycleEvent::AllocationDeferred { policy, count: 4 },
5317 LifecycleEvent::AttemptsUnreadable {
5318 reason: failure_reason_kind(&FailureReason::Other(
5319 "x-api-key: ghp_0123456789abcdefghijklmnopqrstuvwxyz".into(),
5320 )),
5321 },
5322 LifecycleEvent::AttemptCleanFailed {
5323 policy,
5324 attempt,
5325 reason: failure_reason_kind(&FailureReason::ProcessExitedUnexpectedly),
5326 },
5327 LifecycleEvent::AttemptCleaned {
5328 policy,
5329 attempt,
5330 outcome: OutcomeKind::IdleExit,
5331 },
5332 LifecycleEvent::ScaleDownRefused { policy, attempt },
5333 LifecycleEvent::PollScheduled {
5334 retry_in_ms: 900_000,
5335 pace: PollPace::RateLimited {
5336 kind: RateLimitKind::Primary,
5337 },
5338 },
5339 ]
5340 }
5341
5342 /// `e1`'s Definition of Done: *"No emitted event contains a token, a JIT
5343 /// blob, or a credential header."*
5344 ///
5345 /// Asserted by rendering every variant and putting the result through `d1`'s
5346 /// own scrubber: if any of it looked like a credential to the redactor that
5347 /// guards the log file, the round trip would not be the identity. The
5348 /// positive control at the bottom is what stops that assertion passing
5349 /// because the scrubber is asleep.
5350 #[test]
5351 fn no_emitted_event_can_carry_a_credential() {
5352 use runner_manager_platform::logging::redact;
5353
5354 for event in every_event() {
5355 let displayed = event.to_string();
5356 assert_eq!(
5357 redact(&displayed),
5358 displayed,
5359 "`{}` renders something `d1`'s sink would have to redact",
5360 event.name()
5361 );
5362
5363 let debugged = format!("{event:?}");
5364 assert_eq!(
5365 redact(&debugged),
5366 debugged,
5367 "`{}`'s Debug renders something `d1`'s sink would have to redact",
5368 event.name()
5369 );
5370 }
5371
5372 // The control: the scrubber is awake, and would have caught a credential
5373 // had one been there.
5374 let secret = "Authorization: Bearer ghp_0123456789abcdefghijklmnopqrstuvwxyz";
5375 assert_ne!(
5376 redact(secret),
5377 secret,
5378 "the scan above proves nothing if `redact` no longer recognises a credential"
5379 );
5380 }
5381
5382 #[test]
5383 fn every_field_name_this_sink_emits_is_one_d1_allows() {
5384 use runner_manager_platform::logging::is_field_allowed;
5385
5386 // The names `TracingEvents` writes. Kept beside the sink rather than
5387 // derived from it, because a derived list would move with the code and
5388 // assert nothing.
5389 for field in [
5390 "event",
5391 "policy_id",
5392 "attempt_id",
5393 "attempt_state",
5394 "demand",
5395 "desired",
5396 "capacity",
5397 "headroom",
5398 "count",
5399 "reason",
5400 "outcome",
5401 "mode",
5402 "lock",
5403 "retry_in_ms",
5404 "state",
5405 ] {
5406 assert!(
5407 is_field_allowed(field),
5408 "`{field}` is not on `d1`'s allow-list, so this sink would emit \
5409 `[redacted]` in its place and the line would lose its meaning"
5410 );
5411 }
5412 }
5413
5414 #[test]
5415 fn every_failure_reason_has_a_credential_free_kind() {
5416 for reason in FailureReason::ALL {
5417 let kind = failure_reason_kind(&reason);
5418 assert!(!kind.is_empty());
5419 assert!(
5420 kind.chars().all(|c| c.is_ascii_lowercase() || c == '_'),
5421 "`{kind}` is not a fixed identifier"
5422 );
5423 }
5424 assert_eq!(
5425 failure_reason_kind(&FailureReason::Other("ghp_secret".into())),
5426 "other",
5427 "the detail of an `Other` reason never reaches an event"
5428 );
5429 let categories = [
5430 IsolationProviderFailure::NotInstalled,
5431 IsolationProviderFailure::PermissionDenied,
5432 IsolationProviderFailure::ImageUnavailableOrIncompatible,
5433 IsolationProviderFailure::DiskQuotaUnavailable,
5434 IsolationProviderFailure::RuntimeOperationFailed,
5435 ];
5436 let mut kinds = std::collections::BTreeSet::new();
5437 for category in categories {
5438 let kind = failure_reason_kind(&FailureReason::IsolationProvider(category));
5439 assert_ne!(kind, "other");
5440 assert!(!kind.contains("secret"));
5441 kinds.insert(kind);
5442 }
5443 assert_eq!(kinds.len(), categories.len());
5444 }
5445
5446 // =======================================================================
5447 // The two tripwires
5448 // =======================================================================
5449
5450 /// One source file's production half, with comment lines dropped.
5451 ///
5452 /// Both exclusions are `c4`'s, and load-bearing for the same reasons. The
5453 /// **test module** goes because the tests in it legitimately name the shapes
5454 /// they forbid — this module's own positive control is a literal
5455 /// `async fn acquire_jobs`, which would accuse the file of the thing it is
5456 /// proving it does not do. The **comments** go because this module's
5457 /// documentation explains the seam at length and has to name what does not
5458 /// exist in order to say why; a scan that forbade the explanation is a scan
5459 /// that gets the explanation deleted.
5460 fn production_half_of(source: &str) -> String {
5461 let production = source
5462 .split_once("\n#[cfg(test)]")
5463 .map_or(source, |(production, _)| production);
5464 production
5465 .lines()
5466 .filter(|line| !line.trim_start().starts_with("//"))
5467 .collect::<Vec<_>>()
5468 .join("\n")
5469 }
5470
5471 /// This file's own production half.
5472 fn this_file_above_its_tests_without_prose() -> String {
5473 production_half_of(include_str!("reconcile.rs"))
5474 }
5475
5476 /// The one normalisation both halves of the scan use.
5477 ///
5478 /// # This is a second copy of `crates/github/src/demand.rs`, deliberately
5479 ///
5480 /// `production_half_of`, this function, [`FORBIDDEN`] and
5481 /// `forbidden_shape_in` together duplicate `demand.rs:1530-1619`. Sharing
5482 /// them would mean putting them in `crates/testkit`, which `e1` does not
5483 /// own, so the copy was the only option available to this task.
5484 ///
5485 /// **It is worth consolidating later, and here is the specific hazard.**
5486 /// The last defect in `c4`'s copy was two spellings of "the same"
5487 /// normalisation drifting apart — the haystack lower-cased and the needle
5488 /// not — which made three of its seven assertions vacuously true from the
5489 /// day they were written. Two copies is the same hazard one level up. The
5490 /// mitigation inside *this* copy is that one function serves both the scan
5491 /// and its positive control, so a normaliser that stops matching fails the
5492 /// control loudly rather than passing the scan silently; what that cannot
5493 /// catch is this copy and `c4`'s diverging from each other.
5494 fn normalise_for_scan(text: &str) -> String {
5495 text.to_ascii_lowercase().replace(['_', ' '], "")
5496 }
5497
5498 /// The Actions-service call this design has no equivalent of, plus the
5499 /// shapes an implementer would invent in its place.
5500 ///
5501 /// Spelled in halves so that no needle ever appears whole in the text being
5502 /// scanned, and keyed to `fn`/`struct` so that the prose above may keep
5503 /// explaining why there is no reservation. `c4` records both trades at
5504 /// length; this list is its counterpart one layer up. Note that the
5505 /// allocation lock's own `fn acquire` is deliberately *not* matched: the
5506 /// needle is `acquire`-a-**job**, and a lock is not one.
5507 const FORBIDDEN: &[&str] = &[
5508 concat!("fn ", "acquire", "_job"),
5509 concat!("fn ", "claim", "_job"),
5510 concat!("fn ", "lease", "_job"),
5511 concat!("fn ", "reserve", "_job"),
5512 concat!("fn ", "ack", "nowledge"),
5513 concat!("struct ", "Job", "Lease"),
5514 concat!("struct ", "Job", "Claim"),
5515 concat!("struct ", "Job", "Reservation"),
5516 ];
5517
5518 fn forbidden_shape_in(source: &str) -> Option<&'static str> {
5519 let haystack = normalise_for_scan(source);
5520 FORBIDDEN
5521 .iter()
5522 .copied()
5523 .find(|forbidden| haystack.contains(&normalise_for_scan(forbidden)))
5524 }
5525
5526 /// `e1`'s Definition of Done: *"No reservation, claim, lease, or acquisition
5527 /// call exists in the crate; a test or review note records that this is
5528 /// deliberate rather than missing."*
5529 ///
5530 /// **Deliberate, not missing.** The scale-set model let a listener call
5531 /// `AcquireJobs` to claim an assignment before scaling; the REST path has no
5532 /// equivalent, so demand is advisory and two hosts serving the same labels
5533 /// can both start a runner for one queued run. Adding a local reservation
5534 /// table would not remove that — the other host cannot see it — it would
5535 /// only hide the surplus case from the tests that measure it. The three
5536 /// controls that actually bound it are host-scoped routing labels,
5537 /// `max_capacity`, and `host_capacity`, and the last two are enforced in
5538 /// this file.
5539 ///
5540 /// The scan is a tripwire on the obvious shape rather than a proof: a
5541 /// reservation reached through a trait method or a differently-named helper
5542 /// would walk past it. Review is the primary control, exactly as `c4` states
5543 /// for its own copy.
5544 ///
5545 /// # It scans the crate, because the bullet says "in the crate"
5546 ///
5547 /// It used to scan this file alone while quoting a crate-wide claim, which
5548 /// left `lifecycle.rs` — `e3`, the launcher, and by far the likeliest place
5549 /// for someone to "fix" the surplus-runner case with a local lease — covered
5550 /// by nothing. Reading another owner's file is not editing it, so ownership
5551 /// was never the obstacle.
5552 ///
5553 /// The walk below is `c4`'s, and it **recurses** for the reason `c4`
5554 /// records: a module directory (`src/reconcile/mod.rs`) arrives as an entry
5555 /// that does not end in `.rs`, so a flat filter drops it and takes every
5556 /// file underneath with it, leaving the scan passing over files it covers
5557 /// by nothing at all. The listed-versus-on-disk assertion is what stops
5558 /// `SOURCES` going stale the moment `e2` or `e3` adds a module.
5559 #[test]
5560 fn nothing_in_this_crate_reserves_or_claims_a_job() {
5561 const SOURCES: &[(&str, &str)] = &[
5562 ("lib.rs", include_str!("lib.rs")),
5563 ("lifecycle.rs", include_str!("lifecycle.rs")),
5564 (
5565 "lifecycle/windows_hyperv.rs",
5566 include_str!("lifecycle/windows_hyperv.rs"),
5567 ),
5568 ("oci.rs", include_str!("oci.rs")),
5569 ("package.rs", include_str!("package.rs")),
5570 ("reconcile.rs", include_str!("reconcile.rs")),
5571 ];
5572
5573 fn walk(directory: &std::path::Path, prefix: &str, found: &mut Vec<String>) {
5574 for entry in std::fs::read_dir(directory).expect("the crate's own src/ is readable") {
5575 let entry = entry.expect("a readable directory entry");
5576 let name = entry.file_name().to_string_lossy().into_owned();
5577 // `/`-joined, which is what `include_str!` takes on every
5578 // platform, so the two sides compare directly.
5579 let joined = if prefix.is_empty() {
5580 name.clone()
5581 } else {
5582 format!("{prefix}/{name}")
5583 };
5584 if entry.path().is_dir() {
5585 walk(&entry.path(), &joined, found);
5586 } else if name.ends_with(".rs") {
5587 found.push(joined);
5588 }
5589 }
5590 }
5591
5592 let mut listed: Vec<&str> = SOURCES.iter().map(|(name, _)| *name).collect();
5593 listed.sort_unstable();
5594 let mut on_disk = Vec::new();
5595 walk(
5596 std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/src")),
5597 "",
5598 &mut on_disk,
5599 );
5600 on_disk.sort_unstable();
5601 assert_eq!(
5602 listed, on_disk,
5603 "a source file was added or removed; this scan claims to cover the whole crate \
5604 and a stale list makes that claim false"
5605 );
5606
5607 for (name, source) in SOURCES {
5608 assert_eq!(
5609 forbidden_shape_in(&production_half_of(source)),
5610 None,
5611 "{name} names a forbidden shape: there is no `AcquireJobs` equivalent over \
5612 REST, and a local lease coordinates this host with itself and with nothing \
5613 else. If an owner decision restored one, that decision belongs in this \
5614 module's documentation and in this test before it belongs in the code"
5615 );
5616 }
5617
5618 // The control: the scan can see a shape when there is one, through the
5619 // same matcher the loop above uses.
5620 assert!(
5621 forbidden_shape_in("async fn acquire_jobs(&self) -> Vec<Job> { todo!() }").is_some(),
5622 "the scan above proves nothing if the needles no longer match"
5623 );
5624 }
5625
5626 /// This module **applies** `b1`'s label predicate and implements none of it.
5627 ///
5628 /// The counterpart to `c4`'s scan over `crates/github/src/demand.rs`, and it
5629 /// checks the opposite thing, because the two modules sit on opposite sides
5630 /// of the same seam. `c4` builds a `RunsOn` per queued job and must name no
5631 /// `RoutingLabels`; this module holds the policy whose labels decide, so it
5632 /// must call `RoutingLabels::tally` and must not re-derive what that call
5633 /// answers.
5634 ///
5635 /// So the scan is in two halves:
5636 ///
5637 /// * **Present.** `DemandTally` has to appear, because [`demand_for`]
5638 /// returns one. A production half that named it nowhere would mean the
5639 /// filtering had been dropped and every queued job in a watched repository
5640 /// was driving this policy toward `max_capacity` again.
5641 /// * **Absent.** The vocabulary of a *second* implementation. `b1` names the
5642 /// three outcomes of matching one job; this module consumes the aggregate
5643 /// and never a single job's verdict, so naming `RunsOnMatch` or
5644 /// `UnresolvableRunsOn` here means a `match` on an outcome that
5645 /// `RoutingLabels::tally` has already decided — which is how two copies of
5646 /// a predicate start.
5647 ///
5648 /// Like the needles in `nothing_in_this_module_reserves_or_claims_a_job`,
5649 /// this is a tripwire on the obvious shape rather than a proof: a hand-rolled
5650 /// comparison of raw label strings that never names a `policy` type would
5651 /// walk past it. Stated rather than implied, for the same reason it is
5652 /// stated there.
5653 #[test]
5654 fn the_label_predicate_is_b1s_and_this_module_only_applies_it() {
5655 let production = this_file_above_its_tests_without_prose();
5656
5657 assert!(
5658 production.contains("DemandTally"),
5659 "the reconciliation loop must tally queued jobs against this policy's routing \
5660 labels. A production half that named `DemandTally` nowhere would mean the \
5661 label filtering had been removed, and a repository whose jobs target \
5662 `ubuntu-latest` would drive its policy toward `max_capacity` again"
5663 );
5664
5665 for second_implementation in ["RunsOnMatch", "UnresolvableRunsOn"] {
5666 assert!(
5667 !production.contains(second_implementation),
5668 "the reconciliation loop names `{second_implementation}`, which is the \
5669 vocabulary of deciding one job's `runs-on` -- and `RoutingLabels::tally` \
5670 has already decided it. This module applies the predicate and does not \
5671 re-implement it; if an owner decision changed that, it belongs in this \
5672 module's documentation and in this test before it belongs in the code"
5673 );
5674 }
5675 }
5676
5677 /// The demand this module clamps is the *matched* count, and a job this host
5678 /// cannot serve is not demand.
5679 ///
5680 /// The behaviour the whole reversal was for, asserted end to end through
5681 /// `demand_for` rather than through `b1`'s predicate in isolation: a policy
5682 /// carrying this host's labels, a reading holding some of its jobs and some
5683 /// of somebody else's, and the three counts kept apart.
5684 #[test]
5685 fn demand_is_the_queued_jobs_this_policy_can_actually_serve() {
5686 let policy = policy(1, "acme/app", 10);
5687 let reading = QueuedDemand::of(
5688 repo("acme/app"),
5689 [
5690 fixtures::queued_job(&[HOST_LABEL]),
5691 fixtures::queued_job(&[HOST_LABEL]),
5692 fixtures::queued_job(&["ubuntu-latest"]),
5693 fixtures::unresolvable_job(),
5694 ],
5695 );
5696
5697 let tally = demand_for(&policy, &reading);
5698
5699 assert_eq!(
5700 tally.demand(),
5701 2,
5702 "only the jobs whose required labels this policy carries are demand"
5703 );
5704 assert_eq!(
5705 tally.not_matched, 1,
5706 "a `ubuntu-latest` job is somebody else's work; counting it would start a \
5707 runner that idles until it times out"
5708 );
5709 assert_eq!(
5710 tally.unresolvable.len(),
5711 1,
5712 "an unresolvable `runs-on` is never demand and never discarded"
5713 );
5714 }
5715
5716 /// A repository target reads its own repository; an organization target
5717 /// reads the whole scope.
5718 #[test]
5719 fn an_organization_policy_tallies_every_repository_its_scope_covers() {
5720 let mut per_repository = BTreeMap::new();
5721 per_repository.insert(repo("acme/left"), fixtures::queued_jobs(&[HOST_LABEL], 3));
5722 per_repository.insert(repo("acme/right"), fixtures::queued_jobs(&[HOST_LABEL], 4));
5723 let reading = QueuedDemand::new(per_repository);
5724
5725 let repository_policy = policy(1, "acme/left", 10);
5726 assert_eq!(
5727 demand_for(&repository_policy, &reading).demand(),
5728 3,
5729 "a repository target reads its own repository's queue and not the aggregate"
5730 );
5731
5732 let org_policy = fixtures::policy()
5733 .id(PolicyId::from_u128(2))
5734 .organization("acme")
5735 .autoscale("home", 10)
5736 .active()
5737 .build();
5738 assert_eq!(
5739 demand_for(&org_policy, &reading).demand(),
5740 7,
5741 "an organization policy serves any repository in its scope, so its demand is \
5742 the whole aggregate's"
5743 );
5744 }
5745
5746 #[test]
5747 fn the_accepted_over_count_is_bounded_by_the_two_ceilings_and_nothing_else() {
5748 // The owner decision accepts that a repository whose jobs only target
5749 // `ubuntu-latest` still drives its policy toward `max_capacity`. What
5750 // stops that being unbounded is exactly what stops any other demand
5751 // being unbounded, which is asserted here rather than assumed.
5752 let host = host_with(2);
5753 let policy = policy(1, "acme/app", 5);
5754 let attempts: Vec<RunnerAttempt> = Vec::new();
5755 let mut allocator = HostAllocator::from_attempts(&host, &attempts);
5756
5757 let allocation = allocator.allocate(&policy, u32::MAX);
5758 assert_eq!(allocation.desired, 5, "max_capacity beats reported demand");
5759 assert_eq!(allocation.to_start, 2, "host_capacity beats max_capacity");
5760 assert_eq!(allocation.limiting_factor, LimitingFactor::HostCapacity);
5761 }
5762}