Skip to main content

runner_manager_domain/
capacity.rs

1// owner: b1-domain-core
2
3//! The two-level capacity ceiling (D7, D9), expressed as an allocator.
4//!
5//! ```text
6//! demand        = queued jobs whose required labels match this policy's routing labels
7//! desired       = clamp(demand, min_capacity, max_capacity)
8//! host_headroom = host_capacity - active_owned_runners_all_policies
9//! to_start      = max(0, min(desired - active_owned_runners, host_headroom))
10//! ```
11//!
12//! **Why this is a type and not a function.** `b1`'s Scope requires the host
13//! ceiling to be "a first-class allocator over all policies, not a check a caller
14//! may forget". A per-policy `to_start(policy, demand)` free function cannot
15//! enforce D9 at all: the host ceiling is a property of the *set* of policies, so
16//! N policies each individually under their own `max_capacity` still
17//! oversubscribe one machine. [`HostAllocator`] owns the running total, deducts
18//! from it on every grant, and is the only way to obtain a `to_start`, so there
19//! is no call shape in which the ceiling is skipped.
20//!
21//! **Why `- active_owned_runners` is load-bearing.** Under scale sets, demand was
22//! a count of *assigned* jobs. Over REST it is a count of *queued* jobs, and a
23//! job stays queued across consecutive polls while its runner is starting. A
24//! formula that ignored attempts already in flight would start a fresh runner on
25//! every poll until the job was finally picked up — runaway runners, with no
26//! error anywhere. `e1`'s Definition of Done tests three consecutive polls;
27//! [`tests::the_same_queued_job_on_two_polls_yields_one_attempt_not_two`] tests
28//! the arithmetic underneath it.
29//!
30//! **There is no reservation here.** Nothing in this module claims, leases, or
31//! acknowledges a job. The surplus runner that results is an accepted, bounded
32//! cost (`02-target-architecture.md`), and the two ceilings computed here are two
33//! of the three controls that bound it.
34
35use std::fmt;
36use std::num::NonZeroU16;
37
38use crate::attempt::RunnerAttempt;
39use crate::model::{Host, HostId, PolicyId};
40use crate::policy::ScalePolicy;
41
42/// Why `to_start` came out the size it did.
43///
44/// Reported rather than inferred, because "we started fewer runners than demand"
45/// has five quite different causes and an operator staring at a queue needs to
46/// know which one applies. `g2` renders it; `e1` emits it.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum LimitingFactor {
49    /// Demand was fully served.
50    Demand,
51    /// `min_capacity` raised the target above demand. Unreachable in v1, where
52    /// D7 fixes `min_capacity` at 0, but the clamp has two ends and this is the
53    /// other one.
54    MinCapacity,
55    /// The per-policy ceiling bound first.
56    MaxCapacity,
57    /// The host ceiling bound first. D9's whole reason for existing.
58    HostCapacity,
59    /// The policy is monitor-only and owns nothing (D19).
60    MonitorOnly,
61    /// The policy is not `active`, not enabled, or both — a `pending`,
62    /// `draining`, `disabled`, `repair_required`, or `authentication_failed`
63    /// policy starts nothing.
64    NotReconciling,
65    /// The policy belongs to another host. Ownership rule 2.
66    ForeignHost,
67}
68
69impl fmt::Display for LimitingFactor {
70    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71        f.write_str(match self {
72            LimitingFactor::Demand => "demand",
73            LimitingFactor::MinCapacity => "min_capacity",
74            LimitingFactor::MaxCapacity => "max_capacity",
75            LimitingFactor::HostCapacity => "host_capacity",
76            LimitingFactor::MonitorOnly => "monitor_only",
77            LimitingFactor::NotReconciling => "not_reconciling",
78            LimitingFactor::ForeignHost => "foreign_host",
79        })
80    }
81}
82
83/// One policy's share of one reconciliation pass.
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub struct Allocation {
86    pub policy_id: PolicyId,
87    /// Queued jobs matching this policy's routing labels.
88    pub demand: u32,
89    /// `clamp(demand, min_capacity, max_capacity)`.
90    pub desired: u16,
91    /// Attempts already in flight for this policy.
92    pub active_owned: u16,
93    /// Host headroom before this allocation was granted.
94    pub headroom_before: u16,
95    /// How many runners to start now. Never more than the headroom.
96    pub to_start: u16,
97    pub limiting_factor: LimitingFactor,
98}
99
100impl Allocation {
101    #[must_use]
102    pub const fn starts_nothing(&self) -> bool {
103        self.to_start == 0
104    }
105}
106
107/// The host-wide allocator. One per reconciliation pass.
108///
109/// Construct it from the host and every attempt currently on the machine, then
110/// call [`HostAllocator::allocate`] once per policy. Each grant reduces the
111/// remaining headroom, so the sum of `to_start` across all policies in one pass
112/// can never exceed `host_capacity - active_total`.
113///
114/// **The allocator holds the attempt set, and that is the whole design.**
115/// [`Self::from_attempts`] is the only constructor, so both ceilings are derived
116/// from one supply point: the host-wide total (D9) and every per-policy count
117/// (D7) are two different questions asked of the same set. The alternative —
118/// `new(&host, active_total: u16)` alongside a per-call `attempts` argument —
119/// left `HostAllocator::new(&host, 0)` compiling and silently disabling D9's
120/// ceiling, which is exactly the forgettable `u16` the module documentation
121/// above rules out one level up: "a first-class allocator over all policies, not
122/// a check a caller may forget". It also allowed the set to be supplied twice
123/// and disagree with itself. Neither is expressible now.
124#[derive(Debug, Clone, PartialEq, Eq)]
125pub struct HostAllocator<'a> {
126    host_id: HostId,
127    host_capacity: NonZeroU16,
128    /// Every attempt on the machine, across every policy.
129    attempts: Vec<&'a RunnerAttempt>,
130    active_total: u16,
131}
132
133impl<'a> HostAllocator<'a> {
134    /// Build by counting the attempts the machine actually holds.
135    ///
136    /// Passing every attempt across every policy is what makes the host ceiling
137    /// a fact about the machine rather than a number a caller remembered to
138    /// compute.
139    ///
140    /// The total is **not** clamped to `host_capacity` — [`Self::active_total`]
141    /// reports the raw count, over-subscription included, because an operator
142    /// looking at a machine holding more attempts than its ceiling needs to see
143    /// that. What saturates is [`Self::headroom`], which floors at zero: an
144    /// over-subscribed machine has no headroom, rather than negative headroom
145    /// that wraps into a large positive one.
146    #[must_use]
147    pub fn from_attempts(
148        host: &Host,
149        attempts: impl IntoIterator<Item = &'a RunnerAttempt>,
150    ) -> Self {
151        let attempts: Vec<&'a RunnerAttempt> = attempts.into_iter().collect();
152        let active_total = crate::attempt::active_count(attempts.iter().copied());
153        Self {
154            host_id: host.id,
155            host_capacity: host.host_capacity,
156            attempts,
157            active_total,
158        }
159    }
160
161    #[must_use]
162    pub fn host_capacity(&self) -> u16 {
163        self.host_capacity.get()
164    }
165
166    /// Attempts currently occupying a slot, across every policy.
167    #[must_use]
168    pub const fn active_total(&self) -> u16 {
169        self.active_total
170    }
171
172    /// `host_capacity - active_owned_runners_all_policies`, floored at zero.
173    #[must_use]
174    pub fn headroom(&self) -> u16 {
175        self.host_capacity.get().saturating_sub(self.active_total)
176    }
177
178    /// Decide how many runners to start for one policy, and spend the headroom.
179    ///
180    /// `active_owned` — the attempts belonging to `policy` and still occupying a
181    /// slot — is selected from the set this allocator was built with. It is the
182    /// term that stops a job still sitting in the queue from being served twice,
183    /// and it is deliberately read from the *same* set as the host-wide total:
184    /// the two are different questions over one set, the total being host-wide
185    /// (D9) and this one per-policy (D7), and `to_start` is bound by both.
186    ///
187    /// **Why neither this nor the total is a `u16` parameter.** Both were, in
188    /// turn, and each omission is silent rather than loud. A caller passing a
189    /// literal `0` for `active_owned` compiled, ran, and started a fresh runner
190    /// on every poll for a job that was already being served; a caller passing
191    /// `0` for the host total disabled D9's ceiling outright. There is no longer
192    /// a way to write either call. If a caller genuinely holds a pre-computed
193    /// count it can still reach [`crate::attempt::active_count_for`] or
194    /// [`crate::attempt::active_count`] directly and see what it is asking for
195    /// by name.
196    pub fn allocate(&mut self, policy: &ScalePolicy, demand: u32) -> Allocation {
197        let active_owned =
198            crate::attempt::active_count_for(policy.id, self.attempts.iter().copied());
199        let headroom_before = self.headroom();
200
201        let refuse = |limiting_factor| Allocation {
202            policy_id: policy.id,
203            demand,
204            desired: 0,
205            active_owned,
206            headroom_before,
207            to_start: 0,
208            limiting_factor,
209        };
210
211        // Ownership rule 2, checked before anything is spent: an agent may act
212        // only on policies under its own host.
213        if !policy.is_owned_by(self.host_id) {
214            return refuse(LimitingFactor::ForeignHost);
215        }
216        // D19: a monitor-only policy is skipped entirely by reconciliation, and
217        // this is asserted on the mode rather than deduced from `max_capacity`
218        // being absent.
219        if !policy.owns_runners() {
220            return refuse(LimitingFactor::MonitorOnly);
221        }
222        // Precedence rule 4: a user-requested disable beats demand.
223        if !policy.may_start_runners() {
224            return refuse(LimitingFactor::NotReconciling);
225        }
226
227        let min = policy.min_capacity();
228        let max = policy
229            .max_capacity()
230            .expect("an Autoscale policy always has a max_capacity (D19)")
231            .get();
232
233        // `min <= max` was validated when the policy was built
234        // (`policy::AutoscaleConfig::new`), which is what keeps this `clamp`
235        // total -- Rust's `clamp` panics on an inverted range.
236        debug_assert!(min <= max, "PolicyMode invariant");
237        let desired = demand.clamp(u32::from(min), u32::from(max)) as u16;
238
239        let limiting_factor = if demand > u32::from(max) {
240            LimitingFactor::MaxCapacity
241        } else if demand < u32::from(min) {
242            LimitingFactor::MinCapacity
243        } else {
244            LimitingFactor::Demand
245        };
246
247        let wanted = desired.saturating_sub(active_owned);
248        let to_start = wanted.min(headroom_before);
249        let limiting_factor = if to_start < wanted {
250            // The host ceiling bound before the per-policy one did.
251            LimitingFactor::HostCapacity
252        } else {
253            limiting_factor
254        };
255
256        self.active_total = self.active_total.saturating_add(to_start);
257
258        Allocation {
259            policy_id: policy.id,
260            demand,
261            desired,
262            active_owned,
263            headroom_before,
264            to_start,
265            limiting_factor,
266        }
267    }
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273    use crate::attempt::{
274        AttemptOutcome, AttemptState, FailureReason, PersistedAttempt, RunnerAttempt,
275    };
276    use crate::model::{
277        Arch, AttemptId, CachePolicy, HostLabel, Os, PolicyId, ScaleTarget, Timestamp,
278    };
279    use crate::policy::{PolicyMode, RoutingLabels, RunsOn, ScalePolicy};
280    use crate::workspace::WorkspaceKind;
281
282    fn ts(secs: i64) -> Timestamp {
283        chrono::DateTime::from_timestamp(secs, 0).expect("valid timestamp")
284    }
285
286    fn nz(v: u16) -> NonZeroU16 {
287        NonZeroU16::new(v).expect("non-zero")
288    }
289
290    const HOST: HostId = HostId::from_u128(7);
291
292    /// A machine holding nothing. Spelled out rather than written as a bare
293    /// `&[]` so that "this host has no attempts on it" is an assertion the test
294    /// makes on purpose, which is the whole difference between this and the
295    /// `HostAllocator::new(&host, 0)` it replaced.
296    const NO_ATTEMPTS: &[RunnerAttempt] = &[];
297
298    fn host(capacity: u16) -> Host {
299        Host::new(HOST, "home-pc", Os::Windows, Arch::X64, nz(capacity), ts(0)).expect("valid host")
300    }
301
302    fn labels(name: &str) -> RoutingLabels {
303        RoutingLabels::derive(&HostLabel::new(name).unwrap(), Os::Windows, Arch::X64)
304    }
305
306    /// An `active`, enabled autoscale policy — the only kind that ever starts a
307    /// runner.
308    fn active_policy(id: u128, host_label: &str, max: u16) -> ScalePolicy {
309        let mut policy = ScalePolicy::new(
310            PolicyId::from_u128(id),
311            ScaleTarget::repository("o/r").unwrap(),
312            1,
313            HOST,
314            PolicyMode::autoscale(labels(host_label), 0, nz(max)).unwrap(),
315            CachePolicy::default(),
316        );
317        policy.activate().expect("pending -> active");
318        policy
319    }
320
321    /// A journal row in `state`. A terminal state needs a matching outcome, so
322    /// one is supplied rather than letting the fixture build a row the domain
323    /// would refuse to load.
324    fn attempt_in(state: AttemptState, id: u128, policy: u128) -> RunnerAttempt {
325        let outcome = state.is_terminal().then(|| match state {
326            AttemptState::Failed => {
327                AttemptOutcome::failed(FailureReason::ProcessExitedUnexpectedly)
328            }
329            AttemptState::Orphaned => AttemptOutcome::Orphaned,
330            _ => AttemptOutcome::CompletedJob,
331        });
332        RunnerAttempt::from_persisted(PersistedAttempt {
333            id: AttemptId::from_u128(id),
334            policy_id: PolicyId::from_u128(policy),
335            github_runner_id: None,
336            state,
337            outcome,
338            process_id: None,
339            runtime_path: "runtime/p/a".into(),
340            workspace_kind: WorkspaceKind::Ephemeral,
341            workspace_slot: None,
342            created_at: ts(0),
343            terminal_at: state.is_terminal().then(|| ts(0)),
344            last_state_change_at: ts(0),
345        })
346        .expect("a state/outcome pair the domain accepts")
347    }
348
349    // =======================================================================
350    // The clamp, both ends
351    // =======================================================================
352
353    #[test]
354    fn desired_clamps_above_and_below() {
355        let host = host(100);
356        let policy = active_policy(1, "home", 3);
357
358        // Above the ceiling.
359        let mut alloc = HostAllocator::from_attempts(&host, NO_ATTEMPTS);
360        let above = alloc.allocate(&policy, 10);
361        assert_eq!(above.desired, 3, "max_capacity beats reported demand");
362        assert_eq!(above.to_start, 3);
363        assert_eq!(above.limiting_factor, LimitingFactor::MaxCapacity);
364
365        // Inside the range.
366        let mut alloc = HostAllocator::from_attempts(&host, NO_ATTEMPTS);
367        let inside = alloc.allocate(&policy, 2);
368        assert_eq!(inside.desired, 2);
369        assert_eq!(inside.to_start, 2);
370        assert_eq!(inside.limiting_factor, LimitingFactor::Demand);
371
372        // At the floor. D7 fixes min_capacity at 0 in v1, so no demand means no
373        // runners -- the "no idle runners when unused" requirement.
374        let mut alloc = HostAllocator::from_attempts(&host, NO_ATTEMPTS);
375        let none = alloc.allocate(&policy, 0);
376        assert_eq!(none.desired, 0);
377        assert_eq!(none.to_start, 0);
378        assert!(none.starts_nothing());
379    }
380
381    #[test]
382    fn a_non_zero_min_capacity_raises_desired_above_demand() {
383        // The other end of the clamp. Not reachable in v1 (D7 fixes min at 0),
384        // but the formula has two ends and a later warm-minimum feature would
385        // arrive through this path.
386        let host = host(10);
387        let mut policy = ScalePolicy::new(
388            PolicyId::from_u128(1),
389            ScaleTarget::organization("acme").unwrap(),
390            1,
391            HOST,
392            PolicyMode::autoscale(labels("home"), 2, nz(5)).unwrap(),
393            CachePolicy::default(),
394        );
395        policy.activate().unwrap();
396
397        let mut alloc = HostAllocator::from_attempts(&host, NO_ATTEMPTS);
398        let got = alloc.allocate(&policy, 0);
399        assert_eq!(got.desired, 2);
400        assert_eq!(got.to_start, 2);
401        assert_eq!(got.limiting_factor, LimitingFactor::MinCapacity);
402    }
403
404    // =======================================================================
405    // The in-flight term
406    // =======================================================================
407
408    #[test]
409    fn the_same_queued_job_on_two_polls_yields_one_attempt_not_two() {
410        // `b1`: "the same queued job present on two consecutive polls yielding
411        // one attempt, not two". This is the single most likely way `e1` goes
412        // wrong, and it is silent when it does: the operator sees runaway
413        // runners, not an error.
414        //
415        // The job stays `queued` at GitHub for the whole time its runner is
416        // starting, because there is no `AcquireJobs` to take it out of the
417        // queue. So demand reads 1 on every poll.
418        let host = host(4);
419        let policy = active_policy(1, "home", 4);
420        let queued = vec![RunsOn::Single("rm-home-win-x64".into())];
421
422        // Poll 1: nothing in flight.
423        let demand = policy.tally(&queued).demand();
424        assert_eq!(demand, 1);
425        let mut alloc = HostAllocator::from_attempts(&host, NO_ATTEMPTS);
426        let first = alloc.allocate(&policy, demand);
427        assert_eq!(first.to_start, 1);
428
429        // The runner is allocated and starting. The job is *still queued*.
430        let attempts = vec![attempt_in(AttemptState::Starting, 1, 1)];
431
432        // Poll 2, and poll 3: same job, same demand, one attempt in flight.
433        for poll in 2..=3 {
434            let demand = policy.tally(&queued).demand();
435            assert_eq!(demand, 1, "poll {poll}: the job has not left the queue");
436
437            let mut alloc = HostAllocator::from_attempts(&host, &attempts);
438            let again = alloc.allocate(&policy, demand);
439            assert_eq!(
440                again.to_start, 0,
441                "poll {poll} started another runner for a job already being \
442                 served; the `- active_owned_runners` term was dropped from the \
443                 formula"
444            );
445            assert_eq!(again.desired, 1);
446            assert_eq!(again.active_owned, 1);
447        }
448    }
449
450    #[test]
451    fn mutant_ignoring_in_flight_attempts_is_detected() {
452        let host = host(4);
453        let policy = active_policy(1, "home", 4);
454        let attempts = vec![attempt_in(AttemptState::Starting, 1, 1)];
455        let mut allocator = HostAllocator::from_attempts(&host, &attempts);
456        let protected = allocator.allocate(&policy, 1);
457        assert_eq!(protected.active_owned, 1);
458        assert_eq!(protected.to_start, 0);
459
460        // Test-local mutant of the exact subtraction at the production
461        // boundary. It is impossible to compile into a non-test artifact.
462        let mutant_active_owned = 0_u16;
463        let mutant_to_start = protected
464            .desired
465            .saturating_sub(mutant_active_owned)
466            .min(protected.headroom_before);
467        assert_eq!(
468            mutant_to_start, 1,
469            "removing the in-flight term must make the duplicate-poll gate red"
470        );
471    }
472
473    #[test]
474    fn an_attempt_stops_counting_once_it_is_terminal() {
475        let host = host(4);
476        let policy = active_policy(1, "home", 4);
477
478        let in_flight = vec![
479            attempt_in(AttemptState::Allocated, 1, 1),
480            attempt_in(AttemptState::Starting, 2, 1),
481            attempt_in(AttemptState::Busy, 3, 1),
482        ];
483        let mut alloc = HostAllocator::from_attempts(&host, &in_flight);
484        assert_eq!(alloc.active_total(), 3);
485        assert_eq!(alloc.headroom(), 1);
486        assert_eq!(alloc.allocate(&policy, 4).to_start, 1);
487
488        // The same three, all concluded: their slots are back.
489        let done = vec![
490            attempt_in(AttemptState::Finished, 1, 1),
491            attempt_in(AttemptState::Failed, 2, 1),
492            attempt_in(AttemptState::Cleaned, 3, 1),
493        ];
494        let mut alloc = HostAllocator::from_attempts(&host, &done);
495        assert_eq!(alloc.active_total(), 0);
496        assert_eq!(alloc.headroom(), 4);
497        assert_eq!(alloc.allocate(&policy, 4).to_start, 4);
498    }
499
500    #[test]
501    fn one_attempt_set_answers_both_ceilings() {
502        // `HostAllocator::new(&host, active_total: u16)` used to sit beside
503        // `from_attempts`, and `HostAllocator::new(&host, 0)` compiled while
504        // silently disabling D9's ceiling -- the same forgettable `u16` that was
505        // removed from `allocate` one level down. `allocate` also took its own
506        // `attempts` argument, so the set could be supplied twice and disagree
507        // with itself: the residual "pass `&[]` twice" hole.
508        //
509        // Both are closed by there being exactly one supply point. This test
510        // asserts the consequence: the host-wide total and the per-policy count
511        // are read from the same set, without the caller getting a second say.
512        let host = host(10);
513        let mine = active_policy(1, "home", 9);
514        let theirs = active_policy(2, "office", 9);
515
516        let on_the_machine = vec![
517            attempt_in(AttemptState::Busy, 1, 1),
518            attempt_in(AttemptState::Starting, 2, 1),
519            attempt_in(AttemptState::Idle, 3, 2),
520            // Terminal, so it holds no slot in either count.
521            attempt_in(AttemptState::Finished, 4, 1),
522        ];
523
524        let mut alloc = HostAllocator::from_attempts(&host, &on_the_machine);
525        assert_eq!(alloc.active_total(), 3, "host-wide (D9), from the one set");
526
527        let got = alloc.allocate(&mine, 9);
528        assert_eq!(
529            got.active_owned, 2,
530            "per-policy (D7), from the same set and with no second argument that \
531             could have said otherwise"
532        );
533        assert_eq!(got.headroom_before, 7);
534        assert_eq!(got.to_start, 7, "9 wanted, 2 already in flight, 7 free");
535
536        let got = alloc.allocate(&theirs, 9);
537        assert_eq!(got.active_owned, 1);
538        assert_eq!(
539            got.to_start, 0,
540            "the first grant spent the headroom the second would have used"
541        );
542    }
543
544    // =======================================================================
545    // The host ceiling (D9)
546    // =======================================================================
547
548    #[test]
549    fn the_host_ceiling_binds_across_two_policies_whose_max_capacities_sum_higher() {
550        // D9's reason for existing: "A single per-policy limit cannot stop N
551        // policies from jointly oversubscribing one machine."
552        let host = host(3);
553        let a = active_policy(1, "home", 3);
554        let b = active_policy(2, "home", 3);
555
556        let mut alloc = HostAllocator::from_attempts(&host, NO_ATTEMPTS);
557        let first = alloc.allocate(&a, 10);
558        let second = alloc.allocate(&b, 10);
559
560        assert_eq!(first.to_start, 3, "the first policy takes the whole host");
561        assert_eq!(
562            second.to_start, 0,
563            "the second gets nothing; each policy is individually within its own \
564             max_capacity of 3, and 3 + 3 > host_capacity of 3"
565        );
566        assert_eq!(second.limiting_factor, LimitingFactor::HostCapacity);
567        assert_eq!(
568            first.to_start + second.to_start,
569            3,
570            "the sum across policies must never exceed host_capacity"
571        );
572        assert_eq!(alloc.headroom(), 0);
573    }
574
575    #[test]
576    fn the_host_ceiling_splits_headroom_between_policies_in_call_order() {
577        let host = host(5);
578        let a = active_policy(1, "home", 4);
579        let b = active_policy(2, "home", 4);
580        let c = active_policy(3, "home", 4);
581
582        let mut alloc = HostAllocator::from_attempts(&host, NO_ATTEMPTS);
583        let first = alloc.allocate(&a, 4);
584        let second = alloc.allocate(&b, 4);
585        let third = alloc.allocate(&c, 4);
586
587        assert_eq!(first.to_start, 4);
588        assert_eq!(second.to_start, 1, "one slot of headroom left");
589        assert_eq!(second.limiting_factor, LimitingFactor::HostCapacity);
590        assert_eq!(third.to_start, 0);
591        assert_eq!(
592            first.to_start + second.to_start + third.to_start,
593            5,
594            "12 requested across three policies, 5 granted, which is host_capacity"
595        );
596    }
597
598    #[test]
599    fn zero_headroom_starts_nothing_even_at_maximum_demand() {
600        let host = host(2);
601        let policy = active_policy(1, "home", 2);
602
603        let full = vec![
604            attempt_in(AttemptState::Busy, 1, 1),
605            attempt_in(AttemptState::Busy, 2, 1),
606        ];
607        let mut alloc = HostAllocator::from_attempts(&host, &full);
608        assert_eq!(alloc.headroom(), 0);
609
610        let got = alloc.allocate(&policy, u32::from(u16::MAX));
611        assert_eq!(got.to_start, 0);
612        assert_eq!(got.headroom_before, 0);
613        assert_eq!(got.limiting_factor, LimitingFactor::MaxCapacity);
614    }
615
616    #[test]
617    fn headroom_smaller_than_the_per_policy_allowance_wins() {
618        // `b1`: "headroom smaller than the per-policy allowance". A policy
619        // allowed 5 on a host with 2 free slots gets 2.
620        let host = host(6);
621        let policy = active_policy(1, "home", 5);
622
623        let others = vec![
624            attempt_in(AttemptState::Busy, 1, 99),
625            attempt_in(AttemptState::Busy, 2, 99),
626            attempt_in(AttemptState::Idle, 3, 99),
627            attempt_in(AttemptState::Starting, 4, 99),
628        ];
629        let mut alloc = HostAllocator::from_attempts(&host, &others);
630        assert_eq!(alloc.headroom(), 2, "four slots are held by another policy");
631
632        let got = alloc.allocate(&policy, 5);
633        assert_eq!(got.desired, 5, "the policy's own ceiling would allow five");
634        assert_eq!(got.to_start, 2, "but the host has only two slots free");
635        assert_eq!(got.limiting_factor, LimitingFactor::HostCapacity);
636        assert_eq!(alloc.headroom(), 0);
637    }
638
639    #[test]
640    fn an_over_subscribed_host_reports_zero_headroom_rather_than_wrapping() {
641        // If a machine ever genuinely holds more active attempts than its
642        // ceiling -- a lowered `host_capacity`, or a journal written by an older
643        // build -- `host_capacity - active_total` must not wrap to 65535 and
644        // authorise a storm of runners.
645        let host = host(2);
646        let policy = active_policy(1, "home", 10);
647        // Nine live attempts on a host whose ceiling is two. They belong to
648        // another policy, so this is purely about the host-wide term.
649        let oversubscribed: Vec<RunnerAttempt> = (1..=9)
650            .map(|id| attempt_in(AttemptState::Busy, id, 99))
651            .collect();
652
653        let mut alloc = HostAllocator::from_attempts(&host, &oversubscribed);
654        assert_eq!(
655            alloc.active_total(),
656            9,
657            "the raw count is reported, over-subscription included"
658        );
659        assert_eq!(alloc.headroom(), 0);
660        assert_eq!(alloc.allocate(&policy, 10).to_start, 0);
661    }
662
663    // =======================================================================
664    // Precedence: who is allowed to ask at all
665    // =======================================================================
666
667    #[test]
668    fn a_monitor_only_policy_under_maximum_demand_starts_nothing() {
669        // D19. `e1` must "assert this rather than relying on its `max_capacity`
670        // being absent", so the refusal is reported as `MonitorOnly` and not as a
671        // capacity outcome.
672        let host = host(10);
673        let mut policy = ScalePolicy::new(
674            PolicyId::from_u128(1),
675            ScaleTarget::organization("acme").unwrap(),
676            1,
677            HOST,
678            PolicyMode::monitor_only(),
679            CachePolicy::default(),
680        );
681        policy.activate().unwrap();
682
683        let mut alloc = HostAllocator::from_attempts(&host, NO_ATTEMPTS);
684        let got = alloc.allocate(&policy, 1_000);
685        assert_eq!(got.to_start, 0);
686        assert_eq!(got.limiting_factor, LimitingFactor::MonitorOnly);
687        assert_eq!(
688            alloc.headroom(),
689            10,
690            "and it consumes no headroom, so an autoscale policy on the same host \
691             is unaffected"
692        );
693    }
694
695    #[test]
696    fn a_policy_that_is_not_active_and_enabled_starts_nothing() {
697        let host = host(10);
698
699        // Pending: D20 says `add` never arms a host.
700        let pending = ScalePolicy::new(
701            PolicyId::from_u128(1),
702            ScaleTarget::repository("o/r").unwrap(),
703            1,
704            HOST,
705            PolicyMode::autoscale(labels("home"), 0, nz(5)).unwrap(),
706            CachePolicy::default(),
707        );
708        let mut alloc = HostAllocator::from_attempts(&host, NO_ATTEMPTS);
709        let got = alloc.allocate(&pending, 5);
710        assert_eq!(got.to_start, 0);
711        assert_eq!(got.limiting_factor, LimitingFactor::NotReconciling);
712
713        // Draining: precedence rule 4, a user-requested disable beats demand.
714        let mut draining = active_policy(2, "home", 5);
715        draining.request_disable().unwrap();
716        let mut alloc = HostAllocator::from_attempts(&host, NO_ATTEMPTS);
717        let got = alloc.allocate(&draining, 5);
718        assert_eq!(got.to_start, 0);
719        assert_eq!(got.limiting_factor, LimitingFactor::NotReconciling);
720        assert_eq!(alloc.headroom(), 10);
721    }
722
723    #[test]
724    fn a_policy_belonging_to_another_host_is_refused_before_any_headroom_is_spent() {
725        let host = host(4);
726        let mut theirs = ScalePolicy::new(
727            PolicyId::from_u128(1),
728            ScaleTarget::repository("o/r").unwrap(),
729            1,
730            HostId::from_u128(8),
731            PolicyMode::autoscale(labels("office"), 0, nz(4)).unwrap(),
732            CachePolicy::default(),
733        );
734        theirs.activate().unwrap();
735
736        let mut alloc = HostAllocator::from_attempts(&host, NO_ATTEMPTS);
737        let got = alloc.allocate(&theirs, 4);
738        assert_eq!(got.to_start, 0);
739        assert_eq!(got.limiting_factor, LimitingFactor::ForeignHost);
740        assert_eq!(alloc.headroom(), 4);
741    }
742
743    // =======================================================================
744    // The precedence chain, end to end
745    // =======================================================================
746
747    #[test]
748    fn max_capacity_beats_demand_and_host_capacity_beats_max_capacity() {
749        // Precedence rule 5, as one assertion chain.
750        let host = host(2);
751        let policy = active_policy(1, "home", 4);
752
753        let mut alloc = HostAllocator::from_attempts(&host, NO_ATTEMPTS);
754        let got = alloc.allocate(&policy, 9);
755
756        assert_eq!(got.demand, 9);
757        assert_eq!(got.desired, 4, "max_capacity beats reported demand");
758        assert_eq!(got.to_start, 2, "host_capacity beats max_capacity");
759        assert_eq!(got.limiting_factor, LimitingFactor::HostCapacity);
760    }
761
762    #[test]
763    fn an_idle_host_with_no_demand_starts_no_runners() {
764        // "No idle runners when unused" (`02-target-architecture.md`,
765        // traceability table). The whole allocator must return zero.
766        let host = host(8);
767        let policies = [active_policy(1, "home", 4), active_policy(2, "home", 4)];
768        let mut alloc = HostAllocator::from_attempts(&host, NO_ATTEMPTS);
769        for policy in &policies {
770            let got = alloc.allocate(policy, 0);
771            assert_eq!(got.to_start, 0);
772            assert_eq!(got.desired, 0);
773        }
774        assert_eq!(alloc.active_total(), 0);
775        assert_eq!(alloc.headroom(), 8);
776    }
777}