Skip to main content

runner_manager_github/
demand.rs

1// owner: c4-demand-and-jit-gateway
2
3//! How much work is waiting for a runner that does not exist yet.
4//!
5//! Demand is the number `e1` feeds to `clamp(demand, min_capacity,
6//! max_capacity)`, so it is the number that decides how many runner processes
7//! this host starts. Everything in this module exists to make that number
8//! honest — and, where it cannot be, to make it *say* so rather than look
9//! precise.
10//!
11//! ```text
12//! GET /repos/{owner}/{repo}/actions/runs?status=queued&per_page=100
13//! GET /repos/{owner}/{repo}/actions/runs?status=in_progress&per_page=100
14//!   -> 200 { "total_count": N, "workflow_runs": [ { "id": … }, … ] }
15//! GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs?filter=latest&per_page=100
16//!   -> 200 { "total_count": M, "jobs": [ { "status": …, "labels": […] }, … ] }
17//! ```
18//!
19//! # The counting unit is a **job**, and this reverses an earlier decision
20//!
21//! This is the single most important thing to know about this module, and it is
22//! an owner decision that **replaced the opposite owner decision**. The history
23//! is kept here rather than deleted, because the reasoning that produced the old
24//! answer is still sound in the abstract and a later reader who only sees the
25//! new code will otherwise re-derive it and revert this.
26//!
27//! **What the previous decision said.** `d18b-run-count-filtering.md` probed the
28//! runs endpoint against live GitHub and established that its `total_count`
29//! counts **workflow runs matching the query**. Counting runs costs exactly one
30//! request per repository, a fixed figure the budget model
31//! ([`crate::rest::TargetCost`], [`crate::rest::BudgetProjection`]) could price
32//! and `f2`'s `add` refusals could be computed from. Resolving each queued run's
33//! jobs costs one extra request *per run*, a variable cost scaling with queue
34//! depth. The owner chose the fixed cost, accepted the resulting under-count,
35//! and this module said at length: *do not "fix" this, and do not add a per-run
36//! job listing*.
37//!
38//! **Why it was reversed.** A workflow run holds many jobs — a matrix, or a
39//! `jobs:` map with several independent entries — and **each job needs its own
40//! runner**. Under the run count, a single queued run holding eight jobs read as
41//! demand `1`, `e1` started **one** runner, that runner took one job, and the
42//! remaining seven queued behind it. The next poll saw the same run still
43//! queued, still read `1`, and started one more. So a host configured for ten
44//! concurrent runners served an eight-job matrix **serially, roughly one at a
45//! time**, with the queue depth on GitHub growing while the machine sat idle.
46//! The under-count was not a rounding error in the demand signal; on the
47//! workflow shape people actually write, it was the difference between the
48//! product's headline feature working and not working. That is a worse outcome
49//! than a variable request cost, so the trade was re-made the other way.
50//!
51//! **What that means for a later reader.** The per-run job listing below is
52//! deliberate and load-bearing. Removing it to restore a fixed per-repository
53//! cost re-creates the serial-matrix defect described above. If a future owner
54//! decision reverses this again, it belongs in this documentation and in
55//! `tests::the_runs_on_predicate_is_b1s_and_this_module_only_feeds_it` before it
56//! belongs in the code.
57//!
58//! # Both run statuses are polled, and the second one is not redundant
59//!
60//! `status=queued` alone is the obvious query and it is not sufficient. A run's
61//! status is a property of the *run*, and a run holding both a running job and a
62//! queued one has to report one value for both. Live observation on a repository
63//! using this product caught a run of five jobs — two completed, one
64//! `in_progress`, two `queued` — reporting `status: "queued"`, so `queued` does
65//! take precedence over `in_progress` while any job is still waiting.
66//!
67//! That observation is what makes `status=queued` the *primary* signal, and it
68//! is not what makes it sufficient. The case it does not cover is a job that
69//! becomes queued **later**: `needs:` holds a job back until its dependency
70//! finishes, and whether GitHub flips the run's status back to `queued` at that
71//! moment is a claim about a state machine this project has not observed. A
72//! `needs:`-gated job is an ordinary workflow shape, and missing one entirely
73//! would be the same class of defect this module was just rewritten to fix.
74//!
75//! So `status=in_progress` is polled too, as a **safety net rather than a second
76//! primary signal**, and it is budgeted like one: it is read after the queued
77//! runs and gets the smaller of the two run caps
78//! ([`MAX_IN_PROGRESS_RUNS_PER_REPOSITORY_PER_POLL`] against
79//! [`MAX_QUEUED_RUNS_PER_REPOSITORY_PER_POLL`]). A run appears in at most one of
80//! the two lists — the queries are disjoint by construction — and only jobs
81//! whose own `status` is `queued` are counted, so an in-progress run whose jobs
82//! have all been dispatched contributes nothing but the request that discovered
83//! that.
84//!
85//! `completed` and `waiting` runs are not polled. A completed run has no
86//! dispatchable job left, and a `waiting` run is held by a deployment gate or a
87//! concurrency group rather than by the absence of a runner — starting one for
88//! it would produce a runner that idles until its timeout.
89//!
90//! # Routing labels ARE applied now, and the predicate is still `b1`'s
91//!
92//! The previous decision's second accepted cost was that **no routing-label
93//! filtering happened at all**: a run carries no `runs-on`, labels live on jobs,
94//! and this module fetched no jobs. Every queued run in a watched repository
95//! counted, including runs whose jobs targeted `ubuntu-latest` or another host's
96//! `rm-<host>-…` label, and each runner started that way idled until it timed
97//! out.
98//!
99//! Fetching the jobs supplies the input that was missing, so that cost is paid
100//! back by the same change. **This module still owns no predicate.** It reads
101//! each job's `labels` array, builds a [`RunsOn`] from it, and hands that to the
102//! caller. `b1` owns the matching
103//! ([`runner_manager_domain::policy::RoutingLabels::matches`] and its `tally`),
104//! `e1` owns applying it per policy, and
105//! `tests::the_runs_on_predicate_is_b1s_and_this_module_only_feeds_it` scans
106//! this file's own source to pin that no second implementation grows here.
107//!
108//! The filtering is deliberately **not** done in this module even though it now
109//! has the input, and the reason is `e1`'s: one target can be watched by more
110//! than one policy, each with its own routing labels, and `e1` polls a target
111//! once for all of them. A gateway that filtered would have to be told whose
112//! labels to filter by, which would make the poll per-policy and multiply its
113//! request cost by the number of policies sharing the target. So the gateway
114//! returns the jobs and each policy tallies them.
115//!
116//! # What is still approximate, stated plainly rather than left to be discovered
117//!
118//! * **A job listing is a snapshot.** A job that leaves the queue between the
119//!   run list and the job list is counted; one that arrives after is not. The
120//!   next poll corrects both, and `e1`'s per-policy `active_owned` term stops a
121//!   job already being served from being served twice.
122//! * **The run caps make a large queue a floor.** Past
123//!   [`MAX_QUEUED_RUNS_PER_REPOSITORY_PER_POLL`] the count is a *floor* rather
124//!   than a total, reported through [`QueuedDemand::is_truncated`]. Scaling up
125//!   from a floor is safe and successive polls converge; concluding "idle" from
126//!   one is not, which is why the floor is expressible at all.
127//! * **`runs-on` is not always resolvable.** `runs-on: ${{ matrix.runner }}` can
128//!   only be evaluated by GitHub. `b1` reports those as
129//!   [`runner_manager_domain::policy::UnresolvableRunsOn`] and they are neither
130//!   counted as demand nor silently dropped.
131//!
132//! # There is no job reservation, and nothing here may pretend otherwise
133//!
134//! The scale-set model's `AcquireJobs` has no REST equivalent
135//! (`d17-user-to-server-scale-set-chain.md`), so demand is **advisory**: another
136//! host may take a job this host has already started a runner for
137//! (`01-current-architecture.md`, edge case 6).
138//!
139//! **Do not add a claim, a lease, a local reservation table, or an
140//! acknowledgement call to compensate.** No such call exists in this crate, and
141//! `b1`'s, `e1`'s and this task's specifications all say so independently
142//! because implementers keep reaching for one. The bounding controls are the
143//! host-scoped labels in `b1` and the two capacity ceilings in `e1`; a local
144//! lease would coordinate this host with itself and with nothing else, which is
145//! the one thing the problem does not need.
146
147use std::{
148    collections::{BTreeMap, BTreeSet},
149    fmt,
150    sync::{
151        Arc, Mutex,
152        atomic::{AtomicU64, Ordering},
153    },
154    time::Duration,
155};
156
157use runner_manager_domain::{
158    model::{Clock, OwnerRepo, TargetScope, Timestamp},
159    policy::RunsOn,
160};
161use serde::Deserialize;
162
163use crate::{
164    ApiRequest, AuthenticatedClient, GithubError,
165    conditional::{ConditionalCache, Fetched},
166    rest::{
167        ActivityScope, CancelToken, DEFAULT_RATE_LIMIT_BACKOFF, InventoryError,
168        MAX_RATE_LIMIT_BACKOFF, PER_PAGE, RateLimitKind, RateLimited, TargetCost,
169        UnavailableRepository,
170    },
171    traffic::RateLimitSnapshot,
172};
173
174// ---------------------------------------------------------------------------
175// Constants
176// ---------------------------------------------------------------------------
177
178/// The `status` filter that selects runs with a job that may still be waiting.
179///
180/// Stated as a constant rather than inlined because it is the whole difference
181/// between this module and `c3`'s activity count, and a one-word typo here
182/// produces a plausible-looking number rather than an error.
183pub const QUEUED_RUN_STATUS: &str = "queued";
184
185/// The `status` filter for the safety-net pass over runs already under way.
186///
187/// Not a second primary signal. The module documentation states what it covers
188/// that [`QUEUED_RUN_STATUS`] does not — a `needs:`-gated job entering the queue
189/// after its run has already started — and why the difference is not something
190/// this project has observed its way out of needing.
191pub const IN_PROGRESS_RUN_STATUS: &str = "in_progress";
192
193/// The job `status` that means "waiting for a runner that does not exist yet".
194///
195/// This is the value the whole module reduces to. A job in any other state
196/// either has a runner or has finished with one, and counting it would start a
197/// runner for work already being done — the same mistake as reading `c3`'s
198/// in-progress count as demand, one level down.
199pub const QUEUED_JOB_STATUS: &str = "queued";
200
201/// The `filter` for the jobs endpoint: the latest attempt of each job only.
202///
203/// The default is `latest`, and it is sent explicitly because the alternative,
204/// `all`, returns every attempt of every re-run. Under `all` a job re-run three
205/// times contributes three entries, and the two that are historical would be
206/// counted as present demand.
207pub const LATEST_JOBS_FILTER: &str = "latest";
208
209/// Requests one demand poll costs, **per repository**, in steady state.
210///
211/// Four: the two run listings ([`QUEUED_RUN_STATUS`] and
212/// [`IN_PROGRESS_RUN_STATUS`]), plus a job listing for each of the couple of
213/// runs a repository has under way at any moment.
214///
215/// # Why this is a projection and not a measurement, and what bounds it
216///
217/// Under the previous owner decision this constant was `1` and it was exact: one
218/// request per repository, always, because `total_count` on the runs query
219/// answered the whole question. Counting jobs makes the cost depend on how many
220/// runs are active, which is a number no constant can know. So this is the
221/// steady-state figure the budget model prices, in the same spirit as
222/// [`crate::rest::ACTIVITY_REQUESTS_PER_REPOSITORY_PER_REFRESH`], which is also
223/// a best case with a documented worse one.
224///
225/// The **worst** case is `2 + MAX_QUEUED_RUNS_PER_REPOSITORY_PER_POLL +
226/// MAX_IN_PROGRESS_RUNS_PER_REPOSITORY_PER_POLL`, which is what
227/// [`max_demand_requests_per_repository_per_poll`] returns, and it is bounded by
228/// construction rather than by hope: the two caps are hard, and a repository
229/// that reaches them says so through [`QueuedDemand::is_truncated`] rather than
230/// spending more. [`crate::rest::BUDGET_SHARE_DIVISOR`] absorbs the gap between
231/// the two figures — the projection is compared against half the documented
232/// ceiling precisely so that the half nobody models has somewhere to go.
233///
234/// A repository is only at the worst case while it genuinely has that many runs
235/// in flight, which is also exactly when spending requests to scale correctly is
236/// worth more than saving them.
237pub const DEMAND_REQUESTS_PER_REPOSITORY_PER_POLL: u32 = 4;
238
239/// The most `status=queued` runs one repository's job listing may resolve per
240/// poll.
241///
242/// The primary signal gets the larger cap. Past it the reported count is a
243/// **floor** rather than a total, which is safe in the direction that matters:
244/// `e1` clamps demand to `max_capacity` and the host ceiling anyway, so a
245/// repository with more than this many queued runs is one whose real demand
246/// exceeds any realistic host's capacity — the allocation is already pinned at
247/// the ceiling and a larger number would not change it. Successive polls resolve
248/// the rest as the earlier runs drain.
249pub const MAX_QUEUED_RUNS_PER_REPOSITORY_PER_POLL: usize = 6;
250
251/// The most `status=in_progress` runs one repository's job listing may resolve
252/// per poll.
253///
254/// Smaller than [`MAX_QUEUED_RUNS_PER_REPOSITORY_PER_POLL`] on purpose: this
255/// pass exists to catch a `needs:`-gated job whose run has already started, and
256/// on the overwhelmingly common shape it finds nothing and costs one request per
257/// run to discover that. Giving the safety net the same budget as the primary
258/// signal would double the worst case to buy a rarer case.
259pub const MAX_IN_PROGRESS_RUNS_PER_REPOSITORY_PER_POLL: usize = 4;
260
261/// The most pages of jobs one run's listing may walk.
262///
263/// A run's job count is bounded by GitHub's own matrix limit of 256, so two
264/// pages at [`PER_PAGE`] cover every legal run with room to spare. The third is
265/// there because a `Link: rel="next"` chain that does not terminate is a
266/// runaway, and this walk is charged against a budget.
267pub const MAX_JOB_PAGES_PER_RUN: usize = 3;
268
269/// The ceiling on what one repository's demand poll may spend.
270///
271/// Stated as a function rather than a constant because it is the sum of three
272/// constants and a reader checking the budget arithmetic should not have to
273/// re-add them — and because `f1` and `f2` render the worst case beside the
274/// projection.
275#[must_use]
276pub const fn max_demand_requests_per_repository_per_poll() -> u32 {
277    // The two run listings, then one job listing per run each cap admits, and
278    // one per expected run the listings left out.
279    2 + (MAX_QUEUED_RUNS_PER_REPOSITORY_PER_POLL as u32)
280        + (MAX_IN_PROGRESS_RUNS_PER_REPOSITORY_PER_POLL as u32)
281        + (MAX_EXPECTED_RUNS_PER_REPOSITORY_PER_POLL as u32)
282}
283
284/// The most expected runs ([`RestDemand::expect_run`]) one reading of a
285/// repository reads by their id. Spent only on runs the listings left out,
286/// which is no run at all while GitHub's index is right.
287pub const MAX_EXPECTED_RUNS_PER_REPOSITORY_PER_POLL: usize = 4;
288
289/// How many expected runs a repository keeps, the newest.
290const MAX_EXPECTED_RUNS_REMEMBERED: usize = 32;
291
292/// How long a run stays expected. A job waits in the queue at most a day
293/// before GitHub cancels it, and a runner usually takes it within minutes; a
294/// run still queued after this is read again once a webhook names it again.
295pub const EXPECTED_RUN_TTL: Duration = Duration::from_secs(6 * 60 * 60);
296
297// A poll that costs nothing is a poll that issued no request, and a budget line
298// of zero would let `f2` admit an unbounded number of targets.
299const _: () = assert!(
300    DEMAND_REQUESTS_PER_REPOSITORY_PER_POLL > 0,
301    "a demand poll costs at least the requests that fetched the run lists"
302);
303
304// The projection has to sit inside the bound, or the bound is not a bound. This
305// is a compile-time check rather than a test because the two numbers drifting
306// apart is the defect itself rather than a symptom of one: a projection above
307// the ceiling would have `f2` refuse configurations that cannot occur, and the
308// arithmetic that produced it would look deliberate.
309const _: () = assert!(
310    DEMAND_REQUESTS_PER_REPOSITORY_PER_POLL <= max_demand_requests_per_repository_per_poll(),
311    "the projected demand cost must fit inside the worst case the caps allow"
312);
313
314// The projection must also cover the two run listings, which every poll issues
315// unconditionally. A projection below that floor would under-price even a
316// completely idle repository, which is the one case the model must get exactly
317// right: it is what `f1`'s printed ceiling and `f2`'s `add` refusals are
318// computed from.
319const _: () = assert!(
320    DEMAND_REQUESTS_PER_REPOSITORY_PER_POLL >= 2,
321    "every poll issues both run listings, so the projection cannot be below two"
322);
323
324// The safety net must not outgrow the signal it is backing up. If these ever
325// invert, the cheaper `in_progress` pass would be resolving more runs than the
326// `queued` pass that actually carries the demand.
327const _: () = assert!(
328    MAX_IN_PROGRESS_RUNS_PER_REPOSITORY_PER_POLL <= MAX_QUEUED_RUNS_PER_REPOSITORY_PER_POLL,
329    "the in-progress safety net may not be given a larger budget than the primary signal"
330);
331
332/// How far GitHub's `total_count` may exceed the single page it arrived with
333/// before the disagreement stops being a race and starts being evidence.
334///
335/// `c3` states the reasoning in full at its own `MAX_BENIGN_TOTAL_COUNT_SKEW`,
336/// which is private to `crates/github/src/rest.rs`; the value is repeated rather
337/// than shared because this task does not own that file. The short form:
338///
339/// * A run leaving the queue between GitHub computing `total_count` and
340///   serialising the page makes `total` exceed `listed` by a handful. That race
341///   is documented and legitimate, and a debug build pointed at live GitHub is
342///   the build most likely to meet it.
343/// * The defect being hunted — `total_count` carrying the repository's
344///   *unfiltered* lifetime total — is gross: thousands over a page of three.
345///
346/// # This is now a consistency check rather than a load-bearing one
347///
348/// Under the previous owner decision `total_count` **was** the demand number,
349/// and being wrong about it started the wrong number of runners. It no longer
350/// reaches `clamp()`: demand is counted from the jobs, and `total_count` is read
351/// only to notice that a repository has more runs than the caps will resolve.
352/// So the check stays — it is free, and `c3` reads the same envelope for a
353/// dashboard number — but it is a `warn!` and no longer a `debug_assert!`.
354/// Panicking a development build over a field the decision no longer depends on
355/// would be a tripwire that cries wolf, and those get deleted.
356const MAX_BENIGN_TOTAL_COUNT_SKEW: u64 = 16;
357
358// A zero skew is `total == listed` again, which fires on a run leaving the queue
359// mid-serialisation. At compile time rather than in a test because a test
360// deriving its fixture from this constant moves with it and stays green at zero.
361const _: () = assert!(
362    MAX_BENIGN_TOTAL_COUNT_SKEW > 0,
363    "a zero skew re-creates the check that trips on a run leaving the queue mid-serialisation"
364);
365
366// ---------------------------------------------------------------------------
367// The demand reading
368// ---------------------------------------------------------------------------
369
370/// Queued **jobs**, per repository, each carrying the `runs-on` it requires.
371///
372/// **This is a count of jobs, not of runs, and it is not filtered by any
373/// policy's routing labels — but it carries everything needed to filter it.**
374/// Both facts are the module documentation's subject and are repeated on the
375/// type because this is what a caller holds.
376///
377/// The unfiltered totals ([`QueuedDemand::total`],
378/// [`QueuedDemand::for_repository`]) are the raw queue depth, which is what `g2`
379/// renders for an operator: "what is waiting in this repository", independent of
380/// which host could serve it. The number `e1` clamps is **not** either of those.
381/// It comes from tallying [`QueuedDemand::jobs_for`] against one policy's
382/// routing labels, and the difference between the two is exactly the jobs this
383/// host cannot serve.
384///
385/// # A count can be short in two different ways, and both have to say so
386///
387/// A repository can fail to answer at all ([`QueuedDemand::unavailable`]), and a
388/// repository can answer with a number that is only a **floor**
389/// ([`QueuedDemand::truncated`]) — more runs were active than
390/// [`MAX_QUEUED_RUNS_PER_REPOSITORY_PER_POLL`] and
391/// [`MAX_IN_PROGRESS_RUNS_PER_REPOSITORY_PER_POLL`] allow resolving, or one
392/// run's job listing walked to [`MAX_JOB_PAGES_PER_RUN`].
393/// [`QueuedDemand::is_complete`] is `false` for either.
394///
395/// The shape mirrors [`crate::rest::ActivityCount`] deliberately, down to the
396/// method names, because `g2` renders the two side by side and a caller that has
397/// learned one should not have to learn the other. They stay separate types for
398/// the reason `c3` keeps the busy-runner count and the in-progress count apart:
399/// a type that can hold either number is a type that will eventually add them.
400#[derive(Debug, Clone, PartialEq, Eq, Default)]
401pub struct QueuedDemand {
402    per_repository: BTreeMap<OwnerRepo, Vec<RunsOn>>,
403    /// Which job each entry of `per_repository` is, in the same order, where
404    /// the reading came from GitHub's job listing.
405    identities: BTreeMap<OwnerRepo, Vec<QueuedJobIdentity>>,
406    unavailable: Vec<UnavailableRepository>,
407    /// Repositories whose count is a floor rather than a total.
408    truncated: BTreeSet<OwnerRepo>,
409    /// The workflow file of each run the reading listed, by repository.
410    run_workflows: BTreeMap<OwnerRepo, BTreeMap<u64, String>>,
411    /// Repositories with runs under way whose listings **changed** since the
412    /// previous poll. See [`QueuedDemand::is_quiet`].
413    busy: BTreeSet<OwnerRepo>,
414    /// The account's hourly quota as GitHub reported it during this poll.
415    rate_limit: Option<RateLimitSnapshot>,
416}
417
418impl QueuedDemand {
419    #[must_use]
420    pub fn new(per_repository: BTreeMap<OwnerRepo, Vec<RunsOn>>) -> Self {
421        Self {
422            per_repository,
423            identities: BTreeMap::new(),
424            unavailable: Vec::new(),
425            truncated: BTreeSet::new(),
426            run_workflows: BTreeMap::new(),
427            busy: BTreeSet::new(),
428            rate_limit: None,
429        }
430    }
431
432    /// The workflow file of each of `repository`'s active runs the reading saw,
433    /// by run id.
434    #[must_use]
435    pub fn run_workflows_for(&self, repository: &OwnerRepo) -> Option<&BTreeMap<u64, String>> {
436        self.run_workflows.get(repository)
437    }
438
439    /// `repository`'s queued jobs with their identities, where the reading
440    /// has them.
441    pub fn identified_jobs_for(
442        &self,
443        repository: &OwnerRepo,
444    ) -> impl Iterator<Item = (&RunsOn, Option<&QueuedJobIdentity>)> {
445        let identities = self
446            .identities
447            .get(repository)
448            .map_or(&[][..], Vec::as_slice);
449        self.jobs_for(repository)
450            .iter()
451            .enumerate()
452            .map(move |(index, job)| (job, identities.get(index)))
453    }
454
455    /// Says which job each of `repository`'s entries is, in the same order.
456    #[must_use]
457    pub fn with_identities(
458        mut self,
459        repository: OwnerRepo,
460        identities: Vec<QueuedJobIdentity>,
461    ) -> Self {
462        self.identities.insert(repository, identities);
463        self
464    }
465
466    /// One repository's queued jobs, for the repository-target case and for
467    /// tests.
468    #[must_use]
469    pub fn of(repository: OwnerRepo, jobs: impl IntoIterator<Item = RunsOn>) -> Self {
470        Self::new(BTreeMap::from([(
471            repository,
472            jobs.into_iter().collect::<Vec<_>>(),
473        )]))
474    }
475
476    /// Record that `repository`'s count is a floor rather than a total.
477    #[must_use]
478    pub fn with_truncated(mut self, repository: OwnerRepo) -> Self {
479        self.truncated.insert(repository);
480        self
481    }
482
483    /// Record that `repository` has runs under way whose listings changed.
484    #[must_use]
485    pub fn with_busy(mut self, repository: OwnerRepo) -> Self {
486        self.busy.insert(repository);
487        self
488    }
489
490    /// Attach the hourly-quota reading GitHub sent during this poll.
491    #[must_use]
492    pub const fn with_rate_limit(mut self, rate_limit: Option<RateLimitSnapshot>) -> Self {
493        self.rate_limit = rate_limit;
494        self
495    }
496
497    /// Whether nothing is happening in any repository this poll read.
498    ///
499    /// A repository is quiet when it had **no run under way**, or when every
500    /// listing it took to find out was answered `304 Not Modified`. The second
501    /// half matters: a run stuck in the queue for a label no host serves stays
502    /// in the listing for hours, and treating that as activity would hold the
503    /// whole target at the slower active interval for no reason, when its
504    /// listings cost nothing while they do not change.
505    ///
506    /// `e1` polls a quiet target at the host's *idle* interval. That is the
507    /// moment latency matters most, because work arrives after idle, and the
508    /// moment polling is free, because every answer is a `304`.
509    #[must_use]
510    pub fn is_quiet(&self) -> bool {
511        // An unavailable repository is not quiet either: its failing listing
512        // is not cached, so every poll of it is a charged request, and an
513        // idle interval would multiply that by six.
514        self.busy.is_empty() && self.unavailable.is_empty()
515    }
516
517    /// The account's hourly quota as GitHub reported it during this poll.
518    ///
519    /// Every response carries it, a `304` included, and it describes every
520    /// client signed in as the same user, so it is what `e1` slows down on.
521    #[must_use]
522    pub const fn rate_limit(&self) -> Option<&RateLimitSnapshot> {
523        self.rate_limit.as_ref()
524    }
525
526    /// Record that `repository` could not be read at all, and why.
527    ///
528    /// Deliberately **not** the same as a count of zero: nothing was learned
529    /// about it, and a zero would render a possibly-busy repository as idle.
530    #[must_use]
531    pub fn with_unavailable(mut self, repository: OwnerRepo, reason: impl Into<String>) -> Self {
532        self.unavailable.push(UnavailableRepository {
533            repository,
534            reason: reason.into(),
535        });
536        self
537    }
538
539    /// Queued jobs across every repository that answered, **unfiltered**.
540    ///
541    /// The raw queue depth, not the demand any one policy should serve. See the
542    /// type documentation for the distinction, which is the whole reason
543    /// [`QueuedDemand::jobs`] exists beside this.
544    ///
545    /// Saturating rather than wrapping: a total wider than a `u32` is not a
546    /// number to wrap around zero, and `u32::MAX` runners is refused by the
547    /// capacity ceilings long before it means anything.
548    #[must_use]
549    pub fn total(&self) -> u32 {
550        self.per_repository.values().fold(0_u32, |sum, jobs| {
551            sum.saturating_add(u32::try_from(jobs.len()).unwrap_or(u32::MAX))
552        })
553    }
554
555    #[must_use]
556    pub fn per_repository(&self) -> &BTreeMap<OwnerRepo, Vec<RunsOn>> {
557        &self.per_repository
558    }
559
560    /// One repository's unfiltered count, or `None` when it did not answer.
561    #[must_use]
562    pub fn for_repository(&self, repository: &OwnerRepo) -> Option<u32> {
563        self.per_repository
564            .get(repository)
565            .map(|jobs| u32::try_from(jobs.len()).unwrap_or(u32::MAX))
566    }
567
568    /// One repository's queued jobs, as the `runs-on` each requires.
569    ///
570    /// This is the input `b1`'s predicate was written for and never had. An
571    /// empty slice for a repository that answered means it really is idle; a
572    /// repository that did not answer is in [`QueuedDemand::unavailable`]
573    /// instead, and the two must not be conflated.
574    #[must_use]
575    pub fn jobs_for(&self, repository: &OwnerRepo) -> &[RunsOn] {
576        self.per_repository
577            .get(repository)
578            .map_or(&[], Vec::as_slice)
579    }
580
581    /// Every queued job across every repository that answered.
582    ///
583    /// What an organization target tallies, for the reason a repository target
584    /// tallies [`QueuedDemand::jobs_for`]: one policy watching an organization
585    /// serves any repository in it, so its demand is the whole scope's.
586    pub fn jobs(&self) -> impl Iterator<Item = &RunsOn> {
587        self.per_repository.values().flat_map(Vec::as_slice)
588    }
589
590    #[must_use]
591    pub fn unavailable(&self) -> &[UnavailableRepository] {
592        &self.unavailable
593    }
594
595    #[must_use]
596    pub fn truncated(&self) -> &BTreeSet<OwnerRepo> {
597        &self.truncated
598    }
599
600    #[must_use]
601    pub fn is_truncated(&self, repository: &OwnerRepo) -> bool {
602        self.truncated.contains(repository)
603    }
604
605    /// Whether every repository in scope answered with an exact count.
606    ///
607    /// `false` means the total is a **floor**. Scaling *up* from a floor is
608    /// safe; concluding "idle" from one is not, which is the mistake this exists
609    /// to make expressible.
610    #[must_use]
611    pub fn is_complete(&self) -> bool {
612        self.unavailable.is_empty() && self.truncated.is_empty()
613    }
614}
615
616/// Requests one demand poll over `scope` costs, in steady state.
617///
618/// Grows with the repository count, because there is no organization-wide
619/// workflow-runs endpoint and an organization therefore pays per repository the
620/// App is installed on. That growth *is* the product constraint after D4: every
621/// added repository multiplies this policy's share of the shared hourly ceiling.
622#[must_use]
623pub fn demand_requests_per_poll(scope: &ActivityScope) -> u32 {
624    u32::try_from(scope.repositories().len())
625        .unwrap_or(u32::MAX)
626        .saturating_mul(DEMAND_REQUESTS_PER_REPOSITORY_PER_POLL)
627}
628
629/// The most requests one demand poll over `scope` may spend.
630///
631/// The companion to [`demand_requests_per_poll`], and the number a reader should
632/// be shown when they ask why a busy hour cost more than the projection. See
633/// [`DEMAND_REQUESTS_PER_REPOSITORY_PER_POLL`] for why the model prices the
634/// steady state and bounds the peak rather than trying to price the peak.
635#[must_use]
636pub fn max_demand_requests_per_poll(scope: &ActivityScope) -> u32 {
637    u32::try_from(scope.repositories().len())
638        .unwrap_or(u32::MAX)
639        .saturating_mul(max_demand_requests_per_repository_per_poll())
640}
641
642/// `scope`'s budget cost with this module's demand figure substituted for
643/// `c3`'s estimate.
644///
645/// This is the reporting seam `c4`'s specification requires — "report the
646/// per-poll request count to `c3`'s budget model rather than estimating it
647/// there" — and [`TargetCost::with_demand_requests_per_repository`] is where
648/// `c3` left it open. Callers that project a budget (`f1`'s `host show`, `f2`'s
649/// `repo add` and `org add`, `g3`'s settings) should build their
650/// [`TargetCost`] through this function rather than through
651/// [`TargetCost::from_activity_scope`] directly.
652#[must_use]
653pub fn target_cost(scope: &ActivityScope) -> TargetCost {
654    TargetCost::from_activity_scope(scope)
655        .with_demand_requests_per_repository(DEMAND_REQUESTS_PER_REPOSITORY_PER_POLL)
656}
657
658// ---------------------------------------------------------------------------
659// The gateway
660// ---------------------------------------------------------------------------
661
662/// The demand read model.
663///
664/// A trait for [`crate::rest::InventoryGateway`]'s reason: `e1` and `g2` are
665/// tested against `runner_manager_testkit::github::FakeGithub` with no network
666/// and no `wiremock` in their dependency graphs. [`RestDemand`] is the one
667/// implementation that talks to GitHub.
668///
669/// # Why the scope type is `c3`'s `ActivityScope` and not a new one
670///
671/// The name says "activity" and this is demand, so the reuse is worth
672/// justifying rather than leaving to look like an oversight.
673///
674/// [`ActivityScope`] is not a description of the in-progress count; it is the
675/// answer to "which repositories does one per-repository workflow-runs
676/// aggregate cover", and demand asks that question with exactly the same
677/// answer. Both hit `/repos/{o}/{r}/actions/runs`, both have no
678/// organization-wide form, and both take the repository list from the same
679/// caller-held set — `c3` documents that the list "has to come from the caller,
680/// \[because\] `f1` and `e1` already hold it, from
681/// [`crate::AuthenticatedClient::discover_installations`], and re-discovering it
682/// on every refresh would cost more requests than the count itself".
683///
684/// A `DemandScope` beside it would be that type with a different name, and the
685/// cost of the duplicate is concrete rather than aesthetic: `e1` polls both read
686/// models in one refresh, and two scope types are two chances for them to
687/// disagree about which repositories are in scope — which would make the demand
688/// total and the activity total describe different sets while being rendered
689/// side by side. What is *not* shared is the cost function:
690/// [`ActivityScope::requests_per_refresh`] prices the activity count, and
691/// [`demand_requests_per_poll`] prices this one, because those really are two
692/// different numbers.
693#[async_trait::async_trait]
694pub trait DemandGateway: fmt::Debug + Send + Sync {
695    /// Queued workflow runs across `scope`.
696    ///
697    /// # Errors
698    /// Every variant of [`InventoryError`].
699    async fn queued_demand(
700        &self,
701        scope: &ActivityScope,
702        cancel: &CancelToken,
703    ) -> Result<QueuedDemand, InventoryError>;
704
705    /// The workflow file (`.github/workflows/ci.yml`) of one run. Nothing else
706    /// on a run names its workflow stably: its `name` and a job's `workflow_name`
707    /// can be a title that changes at every dispatch.
708    ///
709    /// # Errors
710    /// Every variant of [`InventoryError`].
711    async fn run_workflow(
712        &self,
713        _repository: &OwnerRepo,
714        _run_id: u64,
715        _cancel: &CancelToken,
716    ) -> Result<Option<String>, InventoryError> {
717        Ok(None)
718    }
719
720    /// Reads `run_id`'s jobs in every reading of `repository` until none of
721    /// them is queued, whether or not the run listing shows it. See
722    /// [`RestDemand::expect_run`].
723    fn expect_run(&self, _repository: &OwnerRepo, _run_id: u64) {}
724
725    /// The instant a reading is stamped with.
726    fn now(&self) -> Timestamp;
727}
728
729/// One gateway shared by every target loop on a host. Sharing is what lets
730/// [`RestDemand`] read a repository once for every target that covers it, and
731/// silence every loop at once inside a rate-limit quiet period.
732#[async_trait::async_trait]
733impl<T: DemandGateway + ?Sized> DemandGateway for Arc<T> {
734    async fn queued_demand(
735        &self,
736        scope: &ActivityScope,
737        cancel: &CancelToken,
738    ) -> Result<QueuedDemand, InventoryError> {
739        (**self).queued_demand(scope, cancel).await
740    }
741
742    async fn run_workflow(
743        &self,
744        repository: &OwnerRepo,
745        run_id: u64,
746        cancel: &CancelToken,
747    ) -> Result<Option<String>, InventoryError> {
748        (**self).run_workflow(repository, run_id, cancel).await
749    }
750
751    fn expect_run(&self, repository: &OwnerRepo, run_id: u64) {
752        (**self).expect_run(repository, run_id);
753    }
754
755    fn now(&self) -> Timestamp {
756        (**self).now()
757    }
758}
759
760/// [`DemandGateway`] over `api.github.com`.
761///
762/// Holds no credential of its own: authentication is entirely
763/// [`AuthenticatedClient`]'s, and this type only ever hands it an
764/// [`ApiRequest`].
765///
766/// # Why this is not a method on `c3`'s `RestInventory`
767///
768/// It would be the better shape, and it is not available. `RestInventory::get`
769/// — the one place cancellation, request accounting and the rate-limit gate meet
770/// — is private to `crates/github/src/rest.rs`, and a sibling module cannot
771/// reach a private item. Making it `pub(crate)` is an edit to `c3`'s file, which
772/// this task does not own, so the choice was between duplicating `c3`'s whole
773/// rate-limit *policy* here and consuming what `c3` already exports. This
774/// consumes.
775///
776/// # What "consuming `c3`'s rate-limit policy" means precisely
777///
778/// [`RateLimited::detect`] is `c3`'s decision procedure for whether a failure is
779/// a rate limit and which of GitHub's two it is, including the part that keeps a
780/// permissions `403` landing on an exhausted quota from being misreported as
781/// one. It is public, and this module calls it rather than re-deciding.
782///
783/// What this module deliberately does **not** copy is `c3`'s in-gateway
784/// *back-off latch*, the window during which `RestInventory` opens no socket at
785/// all. A second latch would be a second, quietly divergent copy of a policy
786/// that only works if there is one of it. The scheduling floor is
787/// [`crate::rest::RefreshState::retry_delay`], which `c3` documents as "the
788/// **absolute floor** on when `e1` may try again" — an `e1` honouring it stops
789/// demand polling for the same window, from the layer that owns the schedule.
790///
791/// The residual gap is stated rather than hidden: a rate limit whose *first*
792/// evidence arrives on a demand request does not silence `RestInventory`, and
793/// vice versa. Both report [`InventoryError::RateLimited`] to `e1`, which is the
794/// layer that can act on either.
795pub struct RestDemand {
796    client: Arc<AuthenticatedClient>,
797    clock: Arc<dyn Clock>,
798    requests_issued: AtomicU64,
799    /// Every listing's `ETag` and body, so a repeat poll can be answered `304`.
800    conditional: ConditionalCache,
801    /// One repository's reading, shared between every target that covers it.
802    shared: SharedReadings,
803    /// The host-wide quiet period after GitHub said "rate limited".
804    hold: Mutex<Option<RateLimitHold>>,
805    stats: PollCounters,
806    /// Runs a webhook said were queued, read by their id. See
807    /// [`RestDemand::expect_run`].
808    /// Keyed by the repository's lower-cased name: a webhook's spelling and a
809    /// target's can differ in case.
810    expected: Mutex<BTreeMap<String, BTreeMap<u64, ExpectedRun>>>,
811}
812
813/// A repository's reading, handed to every target that asks for it within a
814/// window.
815///
816/// # Why: one poll per repository, not one per target
817///
818/// The daemon runs one loop per target, and two targets can cover the same
819/// repository: an organization target and a repository target inside it.
820/// Each loop used to poll for itself. With one `RestDemand` shared by every
821/// loop on the host and a window here, a repository is read **once** per
822/// window however many targets, policies and profiles want it, and the others
823/// are handed that reading.
824///
825/// A zero window, the default, shares nothing: every call reads GitHub. That
826/// is what a single-target caller and the tests of the reading itself want.
827struct SharedReadings {
828    /// The window, in milliseconds. Atomic so the daemon can change it when
829    /// the host's idle interval changes, without stopping a loop.
830    window_ms: AtomicU64,
831    /// Pruned of readings older than [`HOLD_MEMORY`] on every insert.
832    readings: Mutex<BTreeMap<OwnerRepo, (Timestamp, RepositoryDemand)>>,
833    /// One gate per repository, so two loops asking at once make one request
834    /// between them.
835    gates: Mutex<BTreeMap<OwnerRepo, Arc<tokio::sync::Mutex<()>>>>,
836}
837
838/// How long an ended rate-limit hold is still remembered as part of the same
839/// episode, and how long an unused shared reading is kept.
840const HOLD_MEMORY: Duration = Duration::from_secs(60 * 60);
841
842/// The rate-limit quiet period, host-wide.
843#[derive(Debug, Clone, Copy)]
844struct RateLimitHold {
845    until: Timestamp,
846    limit: RateLimited,
847    /// Secondary limits in a row. GitHub: "If your request continues to fail
848    /// due to a secondary rate limit, wait for an exponentially increasing
849    /// amount of time between retries."
850    consecutive_secondary: u32,
851}
852
853#[derive(Debug, Default)]
854struct PollCounters {
855    reads: AtomicU64,
856    unchanged_reads: AtomicU64,
857    shared_reads: AtomicU64,
858    suppressed: AtomicU64,
859}
860
861/// What [`RestDemand`] has done, for `status` and `doctor`.
862#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
863pub struct DemandStats {
864    /// Repository readings fetched from GitHub.
865    pub reads: u64,
866    /// Of those, readings where every listing came back `304`.
867    pub unchanged_reads: u64,
868    /// Readings handed to another target instead of being fetched again.
869    pub shared_reads: u64,
870    /// Polls answered "rate limited" without a request, inside the host-wide
871    /// quiet period.
872    pub suppressed: u64,
873}
874
875impl fmt::Debug for RestDemand {
876    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
877        // Hand-written for the reason `AuthenticatedClient`'s own `Debug`
878        // records: nothing here may render a credential, and a derive on a type
879        // holding a client is how that is lost.
880        f.debug_struct("RestDemand")
881            .field(
882                "requests_issued",
883                &self.requests_issued.load(Ordering::Relaxed),
884            )
885            .finish_non_exhaustive()
886    }
887}
888
889impl RestDemand {
890    #[must_use]
891    pub fn new(client: Arc<AuthenticatedClient>, clock: Arc<dyn Clock>) -> Self {
892        Self {
893            conditional: ConditionalCache::new(Arc::clone(&clock)),
894            client,
895            clock,
896            requests_issued: AtomicU64::new(0),
897            shared: SharedReadings {
898                window_ms: AtomicU64::new(0),
899                readings: Mutex::new(BTreeMap::new()),
900                gates: Mutex::new(BTreeMap::new()),
901            },
902            hold: Mutex::new(None),
903            stats: PollCounters::default(),
904            expected: Mutex::new(BTreeMap::new()),
905        }
906    }
907
908    /// Hand a repository's reading to every target that asks within `window`
909    /// of it being fetched. See [`SharedReadings`].
910    ///
911    /// The daemon passes its idle interval: a repository is then read at most
912    /// once per idle interval, and a target sharing it sees a reading no older
913    /// than that.
914    #[must_use]
915    pub fn with_shared_readings(self, window: Duration) -> Self {
916        self.set_shared_window(window);
917        self
918    }
919
920    /// Change the sharing window while running.
921    pub fn set_shared_window(&self, window: Duration) {
922        self.shared.window_ms.store(
923            u64::try_from(window.as_millis()).unwrap_or(u64::MAX),
924            Ordering::SeqCst,
925        );
926    }
927
928    /// Drops `repository`'s shared reading, so the next target to ask for it
929    /// reads GitHub instead of being handed a reading from before.
930    ///
931    /// For a coordinator's wake: GitHub just said this repository's queue
932    /// changed, so a reading shared from a few seconds ago is known stale.
933    /// Nothing else is bypassed — the conditional cache still makes an
934    /// unchanged listing a free `304`, and the rate-limit quiet period still
935    /// withholds every request inside it.
936    pub fn forget_shared_reading(&self, repository: &OwnerRepo) {
937        self.shared
938            .readings
939            .lock()
940            .unwrap_or_else(std::sync::PoisonError::into_inner)
941            .remove(repository);
942    }
943
944    /// Reads `run_id`'s jobs in every reading of `repository` until none of
945    /// them is queued, for at most [`EXPECTED_RUN_TTL`].
946    ///
947    /// # Why the run listing is not enough
948    ///
949    /// GitHub's `GET …/actions/runs?status=queued` is an index that can leave
950    /// a queued run out, and not for a moment: a run whose two jobs waited
951    /// half an hour was in no `status=` listing at all while `GET
952    /// …/actions/runs/{id}` said `queued`. Every host woken for it read zero
953    /// demand and started nothing, and so did the coordinator's sweep. The
954    /// `workflow_job` webhook names the run, so a run named that way is read
955    /// by its id as well.
956    pub fn expect_run(&self, repository: &OwnerRepo, run_id: u64) {
957        let mut expected = lock(&self.expected);
958        let runs = expected.entry(expected_key(repository)).or_default();
959        let now = std::time::Instant::now();
960        runs.entry(run_id)
961            .and_modify(|run| run.named = now)
962            .or_insert(ExpectedRun {
963                named: now,
964                read: None,
965            });
966        // The longest unnamed go first: a burst of webhooks must not grow this
967        // without bound.
968        while runs.len() > MAX_EXPECTED_RUNS_REMEMBERED {
969            let Some(oldest) = runs
970                .iter()
971                .min_by_key(|(_, run)| run.named)
972                .map(|(run_id, _)| *run_id)
973            else {
974                break;
975            };
976            runs.remove(&oldest);
977        }
978    }
979
980    /// The expected runs of `repository` to read now: not `listed`, not past
981    /// [`EXPECTED_RUN_TTL`], at most [`MAX_EXPECTED_RUNS_PER_REPOSITORY_PER_POLL`],
982    /// the longest unread first so every one is read in turn. A listed run is
983    /// read as listed, and no longer expected. The flag says some were left for
984    /// a later reading.
985    fn expected_runs(&self, repository: &OwnerRepo, listed: &BTreeSet<u64>) -> (Vec<u64>, bool) {
986        let key = expected_key(repository);
987        let mut expected = lock(&self.expected);
988        let Some(runs) = expected.get_mut(&key) else {
989            return (Vec::new(), false);
990        };
991        runs.retain(|run_id, run| {
992            run.named.elapsed() < EXPECTED_RUN_TTL && !listed.contains(run_id)
993        });
994        let mut order: Vec<(Option<std::time::Instant>, u64)> = runs
995            .iter()
996            .map(|(run_id, run)| (run.read, *run_id))
997            .collect();
998        order.sort_unstable();
999        let now = std::time::Instant::now();
1000        let chosen: Vec<u64> = order
1001            .iter()
1002            .take(MAX_EXPECTED_RUNS_PER_REPOSITORY_PER_POLL)
1003            .map(|(_, run_id)| *run_id)
1004            .collect();
1005        for run_id in &chosen {
1006            if let Some(run) = runs.get_mut(run_id) {
1007                run.read = Some(now);
1008            }
1009        }
1010        let left = order.len() > chosen.len();
1011        if runs.is_empty() {
1012            expected.remove(&key);
1013        }
1014        (chosen, left)
1015    }
1016
1017    /// `run_id` holds no queued job any more, or cannot be seen.
1018    fn forget_expected_run(&self, repository: &OwnerRepo, run_id: u64) {
1019        let key = expected_key(repository);
1020        let mut expected = lock(&self.expected);
1021        if let Some(runs) = expected.get_mut(&key) {
1022            runs.remove(&run_id);
1023            if runs.is_empty() {
1024                expected.remove(&key);
1025            }
1026        }
1027    }
1028
1029    /// The window readings are shared for.
1030    fn shared_window(&self) -> Duration {
1031        Duration::from_millis(self.shared.window_ms.load(Ordering::SeqCst))
1032    }
1033
1034    /// What this gateway has done.
1035    #[must_use]
1036    pub fn stats(&self) -> DemandStats {
1037        DemandStats {
1038            reads: self.stats.reads.load(Ordering::SeqCst),
1039            unchanged_reads: self.stats.unchanged_reads.load(Ordering::SeqCst),
1040            shared_reads: self.stats.shared_reads.load(Ordering::SeqCst),
1041            suppressed: self.stats.suppressed.load(Ordering::SeqCst),
1042        }
1043    }
1044
1045    /// The client every request goes through, for its traffic counters.
1046    #[must_use]
1047    pub const fn client(&self) -> &Arc<AuthenticatedClient> {
1048        &self.client
1049    }
1050
1051    /// The rate limit still being waited out, with the time left on it.
1052    fn held(&self, now: Timestamp) -> Option<RateLimited> {
1053        let hold = (*self
1054            .hold
1055            .lock()
1056            .unwrap_or_else(std::sync::PoisonError::into_inner))?;
1057        let left = hold
1058            .until
1059            .signed_duration_since(now)
1060            .to_std()
1061            .ok()?
1062            // A clock that jumped backwards must not stretch the hold past
1063            // the longest one this gateway ever sets.
1064            .min(MAX_RATE_LIMIT_BACKOFF);
1065        if left.is_zero() {
1066            return None;
1067        }
1068        Some(RateLimited {
1069            retry_after: Some(left),
1070            ..hold.limit
1071        })
1072    }
1073
1074    /// Start, or lengthen, the host-wide quiet period for `limit`, and say
1075    /// how long it is.
1076    ///
1077    /// # Why a quiet period here, when `e1` already waits
1078    ///
1079    /// `e1`'s schedule waits per loop, and the daemon runs one loop per
1080    /// target. A secondary limit hit by one target's poll used to leave every
1081    /// other target's loop polling on its own schedule, which is exactly the
1082    /// "continuing to make requests while you are rate limited" GitHub warns
1083    /// "may result in the banning of your integration"
1084    /// (<https://docs.github.com/en/rest/using-the-rest-api/best-practices-for-using-the-rest-api#handle-rate-limit-errors-appropriately>).
1085    /// Every loop shares this gateway, so a quiet period here silences them
1086    /// all.
1087    ///
1088    /// The wait is GitHub's own: `retry-after` when sent, else until
1089    /// `x-ratelimit-reset` for the primary limit, else one minute. A secondary
1090    /// limit that recurs doubles it each time, up to
1091    /// [`MAX_RATE_LIMIT_BACKOFF`], which is GitHub's "exponentially increasing
1092    /// amount of time between retries".
1093    fn hold_for(&self, limit: RateLimited, now: Timestamp) -> RateLimited {
1094        let mut hold = self
1095            .hold
1096            .lock()
1097            .unwrap_or_else(std::sync::PoisonError::into_inner);
1098        // A hold that ended long ago is not part of this episode: a secondary
1099        // limit days after the last one starts from one minute again, even if
1100        // nothing but errors came back in between.
1101        let previous = (*hold).filter(|previous| {
1102            now.signed_duration_since(previous.until)
1103                < chrono::TimeDelta::from_std(HOLD_MEMORY).unwrap_or(chrono::TimeDelta::MAX)
1104        });
1105        // The doubling counts EPISODES, not answers. Every target loop shares
1106        // this gateway, so one secondary limit can come back on several
1107        // requests that were in flight together; each of those is the same
1108        // limit, and only an answer arriving after the previous hold ended is
1109        // GitHub saying "still limited".
1110        let live = previous.filter(|previous| previous.until > now);
1111        let consecutive_secondary = match (limit.kind, previous, live) {
1112            (RateLimitKind::Primary, _, _) => 0,
1113            (RateLimitKind::Secondary, _, Some(live)) => live.consecutive_secondary.max(1),
1114            (RateLimitKind::Secondary, Some(previous), None) => {
1115                previous.consecutive_secondary.saturating_add(1)
1116            }
1117            (RateLimitKind::Secondary, None, None) => 1,
1118        };
1119        let mut wait = limit.delay_from(now);
1120        if consecutive_secondary > 1 {
1121            let doubled = DEFAULT_RATE_LIMIT_BACKOFF
1122                .saturating_mul(1_u32 << (consecutive_secondary - 1).min(10));
1123            wait = wait.max(doubled).min(MAX_RATE_LIMIT_BACKOFF);
1124        }
1125        let mut until = now
1126            .checked_add_signed(chrono::TimeDelta::from_std(wait).unwrap_or(chrono::TimeDelta::MAX))
1127            .unwrap_or(now);
1128        // A later answer never SHORTENS a hold in force: a primary limit with
1129        // a near reset must not reopen traffic a secondary limit closed.
1130        if let Some(live) = live
1131            && live.until > until
1132        {
1133            until = live.until;
1134            wait = until.signed_duration_since(now).to_std().unwrap_or(wait);
1135        }
1136        *hold = Some(RateLimitHold {
1137            until,
1138            limit,
1139            consecutive_secondary: consecutive_secondary
1140                .max(live.map_or(0, |live| live.consecutive_secondary)),
1141        });
1142        RateLimited {
1143            retry_after: Some(wait),
1144            ..limit
1145        }
1146    }
1147
1148    /// A request went through: the next secondary limit starts the doubling
1149    /// again from one minute.
1150    fn clear_hold(&self) {
1151        let mut hold = self
1152            .hold
1153            .lock()
1154            .unwrap_or_else(std::sync::PoisonError::into_inner);
1155        if hold.is_some_and(|h| h.until <= self.clock.now()) {
1156            *hold = None;
1157        }
1158    }
1159
1160    /// How many HTTP requests this gateway has issued.
1161    ///
1162    /// [`demand_requests_per_poll`] projects a cost and this measures it. A
1163    /// projection nothing measures is a table in a document, which is why `c3`
1164    /// exposes the same counter and why the tests below assert one against the
1165    /// other.
1166    #[must_use]
1167    pub fn requests_issued(&self) -> u64 {
1168        self.requests_issued.load(Ordering::SeqCst)
1169    }
1170
1171    /// One request, with cancellation applied and a rate limit recognised.
1172    ///
1173    /// Cancellation is consulted twice for `c3`'s reason, and the two are not
1174    /// redundant: [`CancelToken::check`] stops a multi-page walk *between*
1175    /// pages, and [`CancelToken::run`] stops one already blocked on a socket.
1176    ///
1177    /// Every request is **conditional** once its listing has been seen: see
1178    /// [`crate::conditional`]. And none is sent at all inside the host-wide
1179    /// rate-limit quiet period; see [`Self::hold_for`].
1180    async fn get(
1181        &self,
1182        request: &ApiRequest,
1183        cancel: &CancelToken,
1184    ) -> Result<Fetched, InventoryError> {
1185        cancel.check()?;
1186        if let Some(limit) = self.held(self.clock.now()) {
1187            self.stats.suppressed.fetch_add(1, Ordering::SeqCst);
1188            return Err(InventoryError::RateLimited(limit));
1189        }
1190
1191        let result = cancel
1192            .run(async {
1193                // Counted inside the future, so the count is of requests
1194                // actually attempted: `run`'s biased `select!` answers
1195                // `Cancelled` without polling this block when the token is
1196                // already flipped, and no socket is opened.
1197                self.requests_issued.fetch_add(1, Ordering::SeqCst);
1198                self.conditional
1199                    .send(&self.client, request)
1200                    .await
1201                    .map_err(InventoryError::from)
1202            })
1203            .await;
1204        match result {
1205            Ok(fetched) => {
1206                self.clear_hold();
1207                Ok(fetched)
1208            }
1209            Err(InventoryError::Github(error)) => Err(self.classify(error)),
1210            Err(other) => Err(other),
1211        }
1212    }
1213
1214    /// Turn a failure into a rate limit when GitHub's own evidence says it is
1215    /// one, using `c3`'s decision procedure rather than a second one, and
1216    /// start the host-wide quiet period for it.
1217    fn classify(&self, error: GithubError) -> InventoryError {
1218        let Some(limit) = RateLimited::detect(&error) else {
1219            return InventoryError::Github(error);
1220        };
1221        let held = self.hold_for(limit, self.clock.now());
1222        tracing::warn!(
1223            kind = %limit.kind,
1224            remaining = limit.remaining,
1225            quiet_secs = held.retry_after.map_or(0, |wait| wait.as_secs()),
1226            "GitHub is rate limiting this credential; demand for this poll is unknown, not zero, \
1227             and no demand request leaves this host until the quiet period ends"
1228        );
1229        InventoryError::RateLimited(held)
1230    }
1231
1232    /// Queued **jobs** for one repository, each with the `runs-on` it requires.
1233    ///
1234    /// Two run listings, then one job listing per run either cap admits. See
1235    /// [`DEMAND_REQUESTS_PER_REPOSITORY_PER_POLL`] for what that costs in steady
1236    /// state and [`max_demand_requests_per_repository_per_poll`] for the bound.
1237    ///
1238    /// # The order of the two passes is load-bearing
1239    ///
1240    /// Queued runs are resolved first and in-progress runs second, because the
1241    /// caps are spent in that order and the first pass carries the signal. A
1242    /// repository busy enough to exhaust the queued cap gets no in-progress pass
1243    /// at all, which is the right trade: its count is already a floor above any
1244    /// realistic host ceiling, and one more `needs:`-gated job cannot change the
1245    /// allocation.
1246    ///
1247    /// # Why a run is resolved at most once, though the two queries are disjoint
1248    ///
1249    /// They are disjoint *at any one instant*, and these are two requests with
1250    /// time between them. A run that is `queued` when the first is answered and
1251    /// `in_progress` when the second is — which is precisely what happens when
1252    /// its last waiting job gets picked up, so it is the common transition
1253    /// rather than an exotic one — appears in **both** lists. Listing its jobs
1254    /// twice would count every queued job it still holds twice, and demand that
1255    /// double-counts starts runners for work that does not exist.
1256    ///
1257    /// The seen set is what stops that. It is deliberately not an argument that
1258    /// the race is too narrow to matter: the window is one HTTP round trip
1259    /// against a poll that repeats every sixty seconds forever, and the failure
1260    /// it produces is silent inflation rather than an error.
1261    async fn repository_queued(
1262        &self,
1263        repository: &OwnerRepo,
1264        cancel: &CancelToken,
1265    ) -> Result<RepositoryDemand, InventoryError> {
1266        let mut jobs: Vec<(RunsOn, QueuedJobIdentity)> = Vec::new();
1267        let mut exact = true;
1268        let mut changed = false;
1269        let mut resolved: BTreeSet<u64> = BTreeSet::new();
1270        let mut run_workflows = BTreeMap::new();
1271
1272        for (status, cap) in [
1273            (QUEUED_RUN_STATUS, MAX_QUEUED_RUNS_PER_REPOSITORY_PER_POLL),
1274            (
1275                IN_PROGRESS_RUN_STATUS,
1276                MAX_IN_PROGRESS_RUNS_PER_REPOSITORY_PER_POLL,
1277            ),
1278        ] {
1279            let listing = self.active_runs(repository, status, cap, cancel).await?;
1280            exact &= listing.complete;
1281            changed |= listing.changed;
1282            run_workflows.extend(listing.workflows);
1283
1284            for run_id in listing.run_ids {
1285                // A run that changed status between the two listings is in both.
1286                // Resolving it twice would double every queued job it holds.
1287                if !resolved.insert(run_id) {
1288                    continue;
1289                }
1290                let run = self.queued_jobs_of_run(repository, run_id, cancel).await?;
1291                exact &= run.complete;
1292                changed |= run.changed;
1293                let workflow = run_workflows.get(&run_id);
1294                jobs.extend(run.jobs.into_iter().map(|(job, mut identity)| {
1295                    identity.workflow = workflow.cloned();
1296                    (job, identity)
1297                }));
1298            }
1299        }
1300
1301        // The runs a webhook named that the listings left out: see
1302        // `expect_run`. A listed one is read above, and is no longer expected.
1303        let (expected, left) = self.expected_runs(repository, &resolved);
1304        exact &= !left;
1305        for run_id in expected {
1306            let run = match self.queued_jobs_of_run(repository, run_id, cancel).await {
1307                Ok(run) => run,
1308                Err(error) if is_repository_local_failure(&error) => {
1309                    // A deleted run, or one this credential cannot see: not
1310                    // demand, and no reason to fail the whole reading.
1311                    tracing::debug!(%repository, run_id, %error, "an expected run cannot be read");
1312                    self.forget_expected_run(repository, run_id);
1313                    continue;
1314                }
1315                Err(error @ (InventoryError::RateLimited(_) | InventoryError::Cancelled)) => {
1316                    return Err(error);
1317                }
1318                Err(error) => {
1319                    // A passing failure: still expected, and this count a floor.
1320                    tracing::debug!(%repository, run_id, %error, "an expected run could not be read");
1321                    exact = false;
1322                    continue;
1323                }
1324            };
1325            resolved.insert(run_id);
1326            if run.jobs.is_empty() {
1327                self.forget_expected_run(repository, run_id);
1328                continue;
1329            }
1330            tracing::info!(
1331                %repository,
1332                run_id,
1333                queued = run.jobs.len(),
1334                "GitHub's run listing left out a queued run a webhook named; its jobs were read by \
1335                 its id"
1336            );
1337            exact &= run.complete;
1338            changed |= run.changed;
1339            jobs.extend(run.jobs);
1340        }
1341
1342        self.stats.reads.fetch_add(1, Ordering::SeqCst);
1343        if !changed {
1344            self.stats.unchanged_reads.fetch_add(1, Ordering::SeqCst);
1345        }
1346        let (jobs, identities) = jobs.into_iter().unzip();
1347        Ok(RepositoryDemand {
1348            jobs,
1349            identities,
1350            run_workflows,
1351            exact,
1352            busy: changed && !resolved.is_empty(),
1353        })
1354    }
1355
1356    /// One repository's reading, from the shared window when another target
1357    /// fetched it recently enough, from GitHub otherwise.
1358    async fn repository_reading(
1359        &self,
1360        repository: &OwnerRepo,
1361        cancel: &CancelToken,
1362    ) -> Result<RepositoryDemand, InventoryError> {
1363        let window = self.shared_window();
1364        if window.is_zero() {
1365            return self.repository_queued(repository, cancel).await;
1366        }
1367        let gate = Arc::clone(
1368            self.shared
1369                .gates
1370                .lock()
1371                .unwrap_or_else(std::sync::PoisonError::into_inner)
1372                .entry(repository.clone())
1373                .or_default(),
1374        );
1375        // Held across the fetch, so a second loop asking for this repository
1376        // meanwhile waits for this reading instead of making its own request.
1377        let _gate = cancel.run(async { Ok(gate.lock().await) }).await?;
1378        let now = self.clock.now();
1379        let fresh = self
1380            .shared
1381            .readings
1382            .lock()
1383            .unwrap_or_else(std::sync::PoisonError::into_inner)
1384            .get(repository)
1385            .filter(|(at, _)| {
1386                now.signed_duration_since(*at)
1387                    .to_std()
1388                    .is_ok_and(|age| age < window)
1389            })
1390            .map(|(_, reading)| reading.clone());
1391        if let Some(reading) = fresh {
1392            self.stats.shared_reads.fetch_add(1, Ordering::SeqCst);
1393            return Ok(reading);
1394        }
1395        let reading = self.repository_queued(repository, cancel).await?;
1396        let mut readings = self
1397            .shared
1398            .readings
1399            .lock()
1400            .unwrap_or_else(std::sync::PoisonError::into_inner);
1401        readings.insert(repository.clone(), (now, reading.clone()));
1402        // A repository that left an organization's list stops being asked
1403        // for; its reading goes once nobody could use it.
1404        readings.retain(|_, (at, _)| {
1405            now.signed_duration_since(*at)
1406                .to_std()
1407                .is_ok_and(|age| age < HOLD_MEMORY)
1408        });
1409        Ok(reading)
1410    }
1411
1412    /// The ids of one repository's runs in `status`, up to `cap`.
1413    ///
1414    /// **One request, never a page walk.** Both caps are far below [`PER_PAGE`],
1415    /// so the first page always carries more run ids than this may use, and a
1416    /// second page could only contain runs that are already past the cap. That
1417    /// is the whole reason the caps are stated in *runs* rather than in pages:
1418    /// it turns the run listing back into the fixed one-request cost the
1419    /// previous owner decision valued, and spends the variable cost only where
1420    /// it buys the job-level count.
1421    async fn active_runs(
1422        &self,
1423        repository: &OwnerRepo,
1424        status: &str,
1425        cap: usize,
1426        cancel: &CancelToken,
1427    ) -> Result<RunListing, InventoryError> {
1428        let request = ApiRequest::get(format!(
1429            "/repos/{}/{}/actions/runs",
1430            repository.owner(),
1431            repository.repo()
1432        ))
1433        .query("status", status)
1434        .query("per_page", PER_PAGE);
1435
1436        let Fetched {
1437            response,
1438            not_modified,
1439        } = self.get(&request, cancel).await?;
1440        let has_next_page = response.next_page().is_some();
1441        let page: QueuedRunsPage = response.json()?;
1442
1443        let listed = page.workflow_runs.len();
1444        if let Some(total) = page.total_count
1445            && !has_next_page
1446            && total != listed as u64
1447        {
1448            // Free, and no longer load-bearing: see `MAX_BENIGN_TOTAL_COUNT_SKEW`
1449            // for why this is a log line and not the `debug_assert!` it was
1450            // while `total_count` *was* the demand number. The two levels draw
1451            // the same distinction that constant does. A handful over is the
1452            // documented race of a run leaving the queue mid-serialisation,
1453            // which this module's own documentation calls legitimate — warning
1454            // on it every poll is how an operator learns to ignore the warning.
1455            // Thousands over is the unfiltered lifetime total, which is a real
1456            // finding about the API.
1457            if total > (listed as u64).saturating_add(MAX_BENIGN_TOTAL_COUNT_SKEW) {
1458                tracing::warn!(
1459                    repository = %repository,
1460                    status,
1461                    total_count = total,
1462                    listed,
1463                    "GitHub's `total_count` is far larger than the single page it sent for \
1464                     a filtered query, so it is not the filtered count. Demand is counted \
1465                     from the jobs and does not depend on this field, but `c3` reads the \
1466                     same envelope for a dashboard number and does"
1467                );
1468            } else {
1469                tracing::debug!(
1470                    repository = %repository,
1471                    status,
1472                    total_count = total,
1473                    listed,
1474                    "GitHub's `total_count` disagrees slightly with the page it arrived \
1475                     with; this is the documented race of a run leaving the queue while \
1476                     the response was being built"
1477                );
1478            }
1479        }
1480
1481        let run_ids: Vec<u64> = page
1482            .workflow_runs
1483            .iter()
1484            .take(cap)
1485            .map(|run| run.id)
1486            .collect();
1487        let workflows = page
1488            .workflow_runs
1489            .into_iter()
1490            .filter_map(|run| Some((run.id, run.path?)))
1491            .collect();
1492
1493        Ok(RunListing {
1494            // More runs exist than this pass will resolve, so the job count it
1495            // produces is a floor. Both conditions matter: `listed > cap` is the
1496            // ordinary case, and `has_next_page` catches a repository whose
1497            // first page was itself short of the whole filtered set.
1498            complete: listed <= cap && !has_next_page,
1499            run_ids,
1500            workflows,
1501            changed: !not_modified,
1502        })
1503    }
1504
1505    /// One run's jobs that are still waiting for a runner.
1506    ///
1507    /// Jobs in any other state are dropped here rather than downstream, because
1508    /// "queued" is what makes a job demand and a caller holding a mixed list
1509    /// would have to re-derive that. The `runs-on` is rebuilt from the job's
1510    /// `labels` array — which is the array form, so [`RunsOn::from_job_labels`]
1511    /// is the constructor `b1` provides for exactly this.
1512    async fn queued_jobs_of_run(
1513        &self,
1514        repository: &OwnerRepo,
1515        run_id: u64,
1516        cancel: &CancelToken,
1517    ) -> Result<RunJobs, InventoryError> {
1518        let mut request = Some(
1519            ApiRequest::get(format!(
1520                "/repos/{}/{}/actions/runs/{run_id}/jobs",
1521                repository.owner(),
1522                repository.repo()
1523            ))
1524            .query("filter", LATEST_JOBS_FILTER)
1525            .query("per_page", PER_PAGE),
1526        );
1527
1528        let mut jobs = Vec::new();
1529        let mut pages = 0_usize;
1530        let mut changed = false;
1531
1532        while let Some(next) = request.take() {
1533            if pages >= MAX_JOB_PAGES_PER_RUN {
1534                tracing::warn!(
1535                    repository = %repository,
1536                    run_id,
1537                    pages,
1538                    "stopped listing one run's jobs at the page budget; the queued-job \
1539                     count for this repository is a floor, not a total"
1540                );
1541                return Ok(RunJobs {
1542                    jobs,
1543                    complete: false,
1544                    changed,
1545                });
1546            }
1547
1548            let Fetched {
1549                response,
1550                not_modified,
1551            } = self.get(&next, cancel).await?;
1552            changed |= !not_modified;
1553            let following = response
1554                .next_page()
1555                .map(|url| ApiRequest::get(url.as_str()));
1556            let page: RunJobsPage = response.json()?;
1557            pages += 1;
1558
1559            jobs.extend(
1560                page.jobs
1561                    .into_iter()
1562                    .filter(|job| job.status == QUEUED_JOB_STATUS)
1563                    .map(|job| {
1564                        (
1565                            RunsOn::from_job_labels(job.labels),
1566                            QueuedJobIdentity {
1567                                id: job.id,
1568                                name: job.name,
1569                                workflow: None,
1570                                run_id: Some(run_id),
1571                            },
1572                        )
1573                    }),
1574            );
1575
1576            request = following;
1577        }
1578
1579        Ok(RunJobs {
1580            jobs,
1581            complete: true,
1582            changed,
1583        })
1584    }
1585}
1586
1587/// Whether a per-repository failure should be recorded and stepped over, or
1588/// should abort the whole aggregate.
1589///
1590/// The same line `c3` draws in its private `is_repository_local_failure`, and
1591/// repeated here rather than shared because that function lives in a file this
1592/// task does not own. The line is between a fact about *that repository* — a
1593/// `404` for one deleted or renamed, a plain `403` for one with Actions disabled
1594/// — and a fact about the credential or the connection, which stepping over
1595/// would turn into a total short by an unknown amount while looking complete.
1596///
1597/// It applies to an aggregate only. Stepping over the only repository in scope
1598/// would turn a permissions failure into demand `0`, and `e1` would then read
1599/// "nothing queued" for a target it cannot see at all — so the caller checks
1600/// [`TargetScope`] first.
1601fn is_repository_local_failure(error: &InventoryError) -> bool {
1602    match error {
1603        InventoryError::Github(GithubError::Forbidden { .. }) => true,
1604        InventoryError::Github(GithubError::Status { status, .. }) => *status == 404,
1605        _ => false,
1606    }
1607}
1608
1609#[async_trait::async_trait]
1610impl DemandGateway for RestDemand {
1611    async fn queued_demand(
1612        &self,
1613        scope: &ActivityScope,
1614        cancel: &CancelToken,
1615    ) -> Result<QueuedDemand, InventoryError> {
1616        let mut demand = QueuedDemand::default();
1617        // Only an aggregate steps over a bad repository; see
1618        // `is_repository_local_failure`.
1619        let aggregating = scope.target().scope() == TargetScope::Organization;
1620        self.conditional.evict_idle();
1621
1622        for repository in scope.repositories() {
1623            match self.repository_reading(repository, cancel).await {
1624                Ok(reading) => {
1625                    if reading.busy {
1626                        demand.busy.insert(repository.clone());
1627                    }
1628                    demand
1629                        .per_repository
1630                        .insert(repository.clone(), reading.jobs);
1631                    demand
1632                        .identities
1633                        .insert(repository.clone(), reading.identities);
1634                    demand
1635                        .run_workflows
1636                        .insert(repository.clone(), reading.run_workflows);
1637                    if !reading.exact {
1638                        // A floor travels with the aggregate rather than being
1639                        // flattened into it: one truncated repository makes the
1640                        // total a floor too, and nothing downstream has another
1641                        // way to know.
1642                        demand.truncated.insert(repository.clone());
1643                    }
1644                }
1645                Err(error) if aggregating && is_repository_local_failure(&error) => {
1646                    tracing::warn!(
1647                        repository = %repository,
1648                        error = %error,
1649                        "a repository in this organization could not be polled for demand; \
1650                         the aggregate reports it as unavailable rather than as zero"
1651                    );
1652                    demand.unavailable.push(UnavailableRepository {
1653                        repository: repository.clone(),
1654                        reason: error.to_string(),
1655                    });
1656                }
1657                Err(error) => return Err(error),
1658            }
1659        }
1660
1661        demand.rate_limit = self.client.traffic().latest_rate_limit();
1662        Ok(demand)
1663    }
1664
1665    async fn run_workflow(
1666        &self,
1667        repository: &OwnerRepo,
1668        run_id: u64,
1669        cancel: &CancelToken,
1670    ) -> Result<Option<String>, InventoryError> {
1671        let request = ApiRequest::get(format!(
1672            "/repos/{}/{}/actions/runs/{run_id}",
1673            repository.owner(),
1674            repository.repo()
1675        ));
1676        let Fetched { response, .. } = self.get(&request, cancel).await?;
1677        let run: QueuedRun = response.json()?;
1678        Ok(run.path)
1679    }
1680
1681    fn expect_run(&self, repository: &OwnerRepo, run_id: u64) {
1682        RestDemand::expect_run(self, repository, run_id);
1683    }
1684
1685    fn now(&self) -> Timestamp {
1686        self.clock.now()
1687    }
1688}
1689
1690fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
1691    mutex
1692        .lock()
1693        .unwrap_or_else(std::sync::PoisonError::into_inner)
1694}
1695
1696/// A run [`RestDemand::expect_run`] was told of.
1697#[derive(Debug, Clone, Copy)]
1698struct ExpectedRun {
1699    /// When a webhook last named it.
1700    named: std::time::Instant,
1701    /// When a reading last read it by its id.
1702    read: Option<std::time::Instant>,
1703}
1704
1705fn expected_key(repository: &OwnerRepo) -> String {
1706    repository.to_string().to_ascii_lowercase()
1707}
1708
1709// ---------------------------------------------------------------------------
1710// Wire shapes
1711// ---------------------------------------------------------------------------
1712
1713/// Which job a queued entry is, as GitHub's job listing names it: the same id
1714/// and name a `workflow_job` webhook carries. Not its `workflow_name`, which is
1715/// the run's title and changes at every dispatch of a workflow with a `run-name`.
1716#[derive(Debug, Clone, PartialEq, Eq)]
1717pub struct QueuedJobIdentity {
1718    pub id: Option<u64>,
1719    /// The job's name, matrix values expanded.
1720    pub name: Option<String>,
1721    /// Its run's workflow file (`.github/workflows/ci.yml`), which, unlike the
1722    /// title, does not change between dispatches.
1723    pub workflow: Option<String>,
1724    /// Its run.
1725    pub run_id: Option<u64>,
1726}
1727
1728/// One repository's queued jobs, and whether that set is the whole truth.
1729#[derive(Debug, Clone, PartialEq, Eq)]
1730struct RepositoryDemand {
1731    /// The `runs-on` of every job still waiting for a runner.
1732    jobs: Vec<RunsOn>,
1733    /// Which job each of `jobs` is, in the same order.
1734    identities: Vec<QueuedJobIdentity>,
1735    /// The workflow file of each run listed.
1736    run_workflows: BTreeMap<u64, String>,
1737    /// `false` when `jobs` is a **floor**: a run cap or a job page budget
1738    /// stopped the walk before the whole queue had been seen.
1739    exact: bool,
1740    /// Runs were under way and at least one listing changed since the last
1741    /// poll. See [`QueuedDemand::is_quiet`].
1742    busy: bool,
1743}
1744
1745/// One run listing's ids, and whether the cap left any behind.
1746#[derive(Debug, Clone, PartialEq, Eq)]
1747struct RunListing {
1748    run_ids: Vec<u64>,
1749    workflows: BTreeMap<u64, String>,
1750    complete: bool,
1751    /// `false` when GitHub answered `304`.
1752    changed: bool,
1753}
1754
1755/// One run's queued jobs, and whether the page budget saw all of them.
1756#[derive(Debug, Clone, PartialEq, Eq)]
1757struct RunJobs {
1758    jobs: Vec<(RunsOn, QueuedJobIdentity)>,
1759    complete: bool,
1760    /// `false` when every page was answered `304`.
1761    changed: bool,
1762}
1763
1764/// One page of `GET …/actions/runs?status=…`.
1765///
1766/// Only the `id` is read, and it is read because the job listing needs it. That
1767/// is the field the previous owner decision deliberately did not have — the
1768/// module documentation used to point at `jobs_url` and ask nobody to follow it
1769/// — and reversing that decision is what put it here.
1770#[derive(Debug, Deserialize)]
1771struct QueuedRunsPage {
1772    total_count: Option<u64>,
1773    #[serde(default)]
1774    workflow_runs: Vec<QueuedRun>,
1775}
1776
1777/// One workflow run, reduced to the identifier its jobs are fetched by and its
1778/// workflow file.
1779#[derive(Debug, Deserialize)]
1780struct QueuedRun {
1781    id: u64,
1782    #[serde(default)]
1783    path: Option<String>,
1784}
1785
1786/// One page of `GET …/actions/runs/{run_id}/jobs`.
1787#[derive(Debug, Deserialize)]
1788struct RunJobsPage {
1789    #[serde(default)]
1790    jobs: Vec<RunJob>,
1791}
1792
1793/// One job of a run: whether it is still waiting, and what it needs.
1794///
1795/// `labels` is GitHub's flat array form of `runs-on`, which is why
1796/// [`RunsOn::from_job_labels`] exists on `b1`'s side. It defaults to empty
1797/// rather than being required, because a job with no labels is a real answer —
1798/// `b1` reports it as [`runner_manager_domain::policy::UnresolvableRunsOn`] —
1799/// and a missing field should not fail the whole repository's poll.
1800#[derive(Debug, Deserialize)]
1801struct RunJob {
1802    #[serde(default)]
1803    id: Option<u64>,
1804    #[serde(default)]
1805    status: String,
1806    #[serde(default)]
1807    labels: Vec<String>,
1808    #[serde(default)]
1809    name: Option<String>,
1810}
1811
1812// The unit tests below are inline rather than in a `src/demand/tests.rs`, and
1813// that is a constraint rather than a preference: `lib.rs`'s
1814// `the_confidential_credential_scan_covers_every_source_file` walks `src/`
1815// recursively and requires every `.rs` file under it to appear in
1816// `CRATE_SOURCES` — a list in `lib.rs`, which `c2` owns. A second file in this
1817// directory would fail that pin, and the only fix would be editing another
1818// task's file.
1819#[cfg(test)]
1820mod tests {
1821    use super::*;
1822    use crate::testing::{FIXTURE_TOKEN, TestClock};
1823    use crate::{Endpoints, UserAccessToken};
1824    use runner_manager_domain::{
1825        model::{Arch, HostLabel, Label, Org, Os},
1826        policy::{RoutingLabels, RunsOn, RunsOnMatch, UnresolvableRunsOn},
1827    };
1828    use secrecy::SecretString;
1829    use serde_json::json;
1830    use wiremock::{
1831        Mock, MockServer, ResponseTemplate,
1832        matchers::{method, path, query_param},
1833    };
1834
1835    fn repo() -> OwnerRepo {
1836        OwnerRepo::parse("octo/dashboard").expect("a valid owner/repo")
1837    }
1838
1839    fn other_repo() -> OwnerRepo {
1840        OwnerRepo::parse("octo/api").expect("a valid owner/repo")
1841    }
1842
1843    fn third_repo() -> OwnerRepo {
1844        OwnerRepo::parse("octo/tools").expect("a valid owner/repo")
1845    }
1846
1847    fn org_scope(repositories: impl IntoIterator<Item = OwnerRepo>) -> ActivityScope {
1848        ActivityScope::organization(
1849            Org::new("octo-org").expect("a valid organization login"),
1850            repositories,
1851        )
1852    }
1853
1854    fn gateway(server: &MockServer) -> RestDemand {
1855        let endpoints = Endpoints::for_test_server(&server.uri()).expect("a test server base");
1856        let token = UserAccessToken::from_stored(SecretString::from(FIXTURE_TOKEN));
1857        let client = AuthenticatedClient::new(endpoints, token, Arc::new(TestClock::default()))
1858            .expect("a client over the test server");
1859        RestDemand::new(Arc::new(client), Arc::new(TestClock::default()))
1860    }
1861
1862    // -- fixtures -----------------------------------------------------------
1863
1864    /// A run list carrying `ids`, and a `total_count` that agrees with it.
1865    fn runs_body(ids: &[u64]) -> serde_json::Value {
1866        json!({
1867            "total_count": ids.len(),
1868            "workflow_runs": ids.iter().map(|id| json!({ "id": id })).collect::<Vec<_>>()
1869        })
1870    }
1871
1872    /// An empty run list, which is what an idle repository answers.
1873    fn no_runs() -> serde_json::Value {
1874        runs_body(&[])
1875    }
1876
1877    /// A jobs page: `queued` jobs carrying `labels`, then `running` that do not
1878    /// count.
1879    fn jobs_body(labels: &[&str], queued: usize, running: usize) -> serde_json::Value {
1880        let mut jobs = Vec::new();
1881        for _ in 0..queued {
1882            jobs.push(json!({ "status": "queued", "labels": labels }));
1883        }
1884        for _ in 0..running {
1885            jobs.push(json!({ "status": "in_progress", "labels": labels }));
1886        }
1887        json!({ "total_count": jobs.len(), "jobs": jobs })
1888    }
1889
1890    fn runs_path(repository: &OwnerRepo) -> String {
1891        format!(
1892            "/repos/{}/{}/actions/runs",
1893            repository.owner(),
1894            repository.repo()
1895        )
1896    }
1897
1898    fn jobs_path(repository: &OwnerRepo, run_id: u64) -> String {
1899        format!("{}/{run_id}/jobs", runs_path(repository))
1900    }
1901
1902    /// Mount one repository's run list for one status filter.
1903    async fn mount_runs(
1904        server: &MockServer,
1905        repository: &OwnerRepo,
1906        status: &str,
1907        body: serde_json::Value,
1908    ) {
1909        Mock::given(method("GET"))
1910            .and(path(runs_path(repository)))
1911            .and(query_param("status", status))
1912            .respond_with(ResponseTemplate::new(200).set_body_json(body))
1913            .mount(server)
1914            .await;
1915    }
1916
1917    /// Mount one run's job list.
1918    async fn mount_jobs(
1919        server: &MockServer,
1920        repository: &OwnerRepo,
1921        run_id: u64,
1922        body: serde_json::Value,
1923    ) {
1924        Mock::given(method("GET"))
1925            .and(path(jobs_path(repository, run_id)))
1926            .respond_with(ResponseTemplate::new(200).set_body_json(body))
1927            .mount(server)
1928            .await;
1929    }
1930
1931    /// The ordinary shape: one queued run of `queued` + `running` jobs, and an
1932    /// empty in-progress list.
1933    async fn mount_one_queued_run(
1934        server: &MockServer,
1935        repository: &OwnerRepo,
1936        labels: &[&str],
1937        queued: usize,
1938        running: usize,
1939    ) {
1940        mount_runs(server, repository, QUEUED_RUN_STATUS, runs_body(&[100])).await;
1941        mount_runs(server, repository, IN_PROGRESS_RUN_STATUS, no_runs()).await;
1942        mount_jobs(server, repository, 100, jobs_body(labels, queued, running)).await;
1943    }
1944
1945    /// A repository with nothing waiting: two run listings, no job listings.
1946    async fn mount_idle(server: &MockServer, repository: &OwnerRepo) {
1947        mount_runs(server, repository, QUEUED_RUN_STATUS, no_runs()).await;
1948        mount_runs(server, repository, IN_PROGRESS_RUN_STATUS, no_runs()).await;
1949    }
1950
1951    /// The routing labels of a realistic policy on this host.
1952    ///
1953    /// Deliberately **not** a bare [`RoutingLabels::derive`]. That produces the
1954    /// derived host label alone, and a job written `runs-on: [self-hosted,
1955    /// windows]` — the shape people actually write — requires labels a bare
1956    /// derived set does not carry, so it would not match. An operator adds those
1957    /// with `repo add --label`, and a fixture that skipped them would test the
1958    /// filtering against a policy nobody configures.
1959    fn host_labels() -> RoutingLabels {
1960        RoutingLabels::from_parts(
1961            Label::new("rm-home-win-x64").expect("a valid label"),
1962            [
1963                Label::new("self-hosted").expect("a valid label"),
1964                Label::new("windows").expect("a valid label"),
1965            ],
1966        )
1967    }
1968
1969    // -- the count itself ---------------------------------------------------
1970
1971    /// The defect this module was rewritten for, as an executable statement.
1972    ///
1973    /// One queued run holding eight matrix jobs. Under the previous owner
1974    /// decision this repository reported demand `1`, `e1` started one runner,
1975    /// and the other seven jobs waited for a machine that was sitting idle. It
1976    /// must now report `8`.
1977    #[tokio::test]
1978    async fn a_matrix_run_of_eight_jobs_is_eight_units_of_demand_and_not_one() {
1979        let server = MockServer::start().await;
1980        mount_one_queued_run(&server, &repo(), &["rm-home-win-x64"], 8, 0).await;
1981        let gateway = gateway(&server);
1982
1983        let demand = gateway
1984            .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
1985            .await
1986            .expect("a queued-job count");
1987
1988        assert_eq!(
1989            demand.total(),
1990            8,
1991            "eight jobs in one run are eight runners' worth of work; reading the run \
1992             count here is the defect that forced the owner decision back"
1993        );
1994        assert_eq!(demand.for_repository(&repo()), Some(8));
1995        assert!(demand.is_complete());
1996        assert_eq!(
1997            gateway.requests_issued(),
1998            3,
1999            "two run listings and one job listing for the single active run"
2000        );
2001    }
2002
2003    /// Two jobs of a run waited half an hour while GitHub's `status=queued`
2004    /// listing, and every other `status=`, left the run out. The webhook named
2005    /// the run: it is read by its id until none of its jobs is queued.
2006    #[tokio::test]
2007    async fn an_expected_run_the_listing_leaves_out_is_read_by_its_id() {
2008        let server = MockServer::start().await;
2009        mount_idle(&server, &repo()).await;
2010        mount_jobs(&server, &repo(), 77, jobs_body(&["self-hosted"], 2, 0)).await;
2011        let gateway = gateway(&server);
2012        let scope = ActivityScope::repository(repo());
2013
2014        let unexpected = gateway
2015            .queued_demand(&scope, &CancelToken::new())
2016            .await
2017            .expect("a reading");
2018        assert_eq!(unexpected.total(), 0, "nothing names the run yet");
2019
2020        gateway.expect_run(&repo(), 77);
2021        let demand = gateway
2022            .queued_demand(&scope, &CancelToken::new())
2023            .await
2024            .expect("a reading");
2025        assert_eq!(demand.total(), 2);
2026        let runs: Vec<_> = demand
2027            .identified_jobs_for(&repo())
2028            .map(|(_, identity)| identity.and_then(|identity| identity.run_id))
2029            .collect();
2030        assert_eq!(runs, [Some(77), Some(77)]);
2031
2032        // A runner took both: the run is no longer expected, and costs nothing.
2033        server.reset().await;
2034        mount_idle(&server, &repo()).await;
2035        mount_jobs(&server, &repo(), 77, jobs_body(&["self-hosted"], 0, 2)).await;
2036        let taken = gateway
2037            .queued_demand(&scope, &CancelToken::new())
2038            .await
2039            .expect("a reading");
2040        assert_eq!(taken.total(), 0);
2041        assert!(lock(&gateway.expected).is_empty());
2042    }
2043
2044    /// The coordinator's sweep holds its gateway as `Arc<dyn DemandGateway>`:
2045    /// the expectation must reach the gateway through the trait, and in any
2046    /// spelling of the repository's name. More expected runs than one reading
2047    /// reads are read in turn, and the reading says it is a floor until then.
2048    #[tokio::test]
2049    async fn expected_runs_reach_through_the_trait_and_are_read_in_turn() {
2050        let server = MockServer::start().await;
2051        mount_idle(&server, &repo()).await;
2052        let runs: Vec<u64> = (0..6).map(|i| 300 + i).collect();
2053        for id in &runs {
2054            mount_jobs(&server, &repo(), *id, jobs_body(&["self-hosted"], 1, 0)).await;
2055        }
2056        let concrete = Arc::new(gateway(&server));
2057        let gateway: Arc<dyn DemandGateway> = Arc::clone(&concrete) as Arc<dyn DemandGateway>;
2058        let shouted = OwnerRepo::parse(repo().to_string().to_ascii_uppercase()).expect("repo");
2059        for id in &runs {
2060            gateway.expect_run(&shouted, *id);
2061        }
2062        let scope = ActivityScope::repository(repo());
2063
2064        let first = gateway
2065            .queued_demand(&scope, &CancelToken::new())
2066            .await
2067            .expect("a reading");
2068        assert_eq!(
2069            first.total(),
2070            MAX_EXPECTED_RUNS_PER_REPOSITORY_PER_POLL as u32
2071        );
2072        assert!(!first.is_complete(), "two expected runs were left unread");
2073        let second = gateway
2074            .queued_demand(&scope, &CancelToken::new())
2075            .await
2076            .expect("a reading");
2077        let read: BTreeSet<u64> = first
2078            .identified_jobs_for(&repo())
2079            .chain(second.identified_jobs_for(&repo()))
2080            .filter_map(|(_, identity)| identity.and_then(|identity| identity.run_id))
2081            .collect();
2082        assert_eq!(
2083            read,
2084            runs.iter().copied().collect(),
2085            "every run is read in turn"
2086        );
2087    }
2088
2089    /// A passing failure keeps the run expected: the webhook named it once.
2090    #[tokio::test]
2091    async fn an_expected_run_survives_a_passing_failure() {
2092        let server = MockServer::start().await;
2093        mount_idle(&server, &repo()).await;
2094        Mock::given(method("GET"))
2095            .and(path(jobs_path(&repo(), 79)))
2096            .respond_with(ResponseTemplate::new(502))
2097            .mount(&server)
2098            .await;
2099        let gateway = gateway(&server);
2100        gateway.expect_run(&repo(), 79);
2101        let scope = ActivityScope::repository(repo());
2102        let failed = gateway
2103            .queued_demand(&scope, &CancelToken::new())
2104            .await
2105            .expect("a reading");
2106        assert_eq!(failed.total(), 0);
2107        assert!(!failed.is_complete());
2108
2109        server.reset().await;
2110        mount_idle(&server, &repo()).await;
2111        mount_jobs(&server, &repo(), 79, jobs_body(&["self-hosted"], 1, 0)).await;
2112        let demand = gateway
2113            .queued_demand(&scope, &CancelToken::new())
2114            .await
2115            .expect("a reading");
2116        assert_eq!(demand.total(), 1);
2117    }
2118
2119    /// An expected run that cannot be read (deleted) is not demand and does
2120    /// not fail the reading.
2121    #[tokio::test]
2122    async fn an_expected_run_that_is_gone_is_forgotten() {
2123        let server = MockServer::start().await;
2124        mount_one_queued_run(&server, &repo(), &["rm-home-win-x64"], 1, 0).await;
2125        Mock::given(method("GET"))
2126            .and(path(jobs_path(&repo(), 78)))
2127            .respond_with(ResponseTemplate::new(404))
2128            .mount(&server)
2129            .await;
2130        let gateway = gateway(&server);
2131        gateway.expect_run(&repo(), 78);
2132        // A listed run is read once, as listed.
2133        gateway.expect_run(&repo(), 100);
2134
2135        let demand = gateway
2136            .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
2137            .await
2138            .expect("a reading");
2139        assert_eq!(demand.total(), 1);
2140        assert!(lock(&gateway.expected).is_empty());
2141    }
2142
2143    /// A job that already has a runner is not demand.
2144    ///
2145    /// The run-level mistake one level down: `status=in_progress` work already
2146    /// has a runner, and counting it would start a second for the same job.
2147    #[tokio::test]
2148    async fn only_jobs_still_queued_are_counted() {
2149        let server = MockServer::start().await;
2150        mount_one_queued_run(&server, &repo(), &["rm-home-win-x64"], 3, 5).await;
2151        let gateway = gateway(&server);
2152
2153        let demand = gateway
2154            .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
2155            .await
2156            .expect("a queued-job count");
2157
2158        assert_eq!(
2159            demand.total(),
2160            3,
2161            "five of the eight jobs already have a runner and are not waiting for one"
2162        );
2163    }
2164
2165    /// The labels travel with the job, which is the input `b1`'s predicate never
2166    /// had.
2167    #[tokio::test]
2168    async fn each_queued_job_carries_the_runs_on_it_requires() {
2169        let server = MockServer::start().await;
2170        mount_runs(&server, &repo(), QUEUED_RUN_STATUS, runs_body(&[100, 101])).await;
2171        mount_runs(&server, &repo(), IN_PROGRESS_RUN_STATUS, no_runs()).await;
2172        mount_jobs(
2173            &server,
2174            &repo(),
2175            100,
2176            jobs_body(&["rm-home-win-x64", "self-hosted", "windows"], 2, 0),
2177        )
2178        .await;
2179        mount_jobs(&server, &repo(), 101, jobs_body(&["ubuntu-latest"], 4, 0)).await;
2180        let gateway = gateway(&server);
2181
2182        let demand = gateway
2183            .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
2184            .await
2185            .expect("a queued-job count");
2186
2187        assert_eq!(
2188            demand.total(),
2189            6,
2190            "the unfiltered depth is every queued job"
2191        );
2192
2193        // And the number `e1` clamps, which is the unfiltered depth put through
2194        // `b1`'s predicate. This gateway does not compute it; it supplies it.
2195        let tally = host_labels().tally(demand.jobs_for(&repo()));
2196        assert_eq!(
2197            tally.demand(),
2198            2,
2199            "the four `ubuntu-latest` jobs are somebody else's work; before the job \
2200             listing existed all six would have driven this policy toward max_capacity"
2201        );
2202        assert_eq!(tally.not_matched, 4);
2203    }
2204
2205    /// The two run statuses are both on the wire, and the job filter with them.
2206    ///
2207    /// The queries are one word apart and answer different questions, so a
2208    /// gateway that sent the wrong one would return a plausible number. The
2209    /// mocks match on the exact status, so sending anything else 404s here
2210    /// rather than passing.
2211    #[tokio::test]
2212    async fn both_run_statuses_are_polled_and_the_jobs_request_asks_for_the_latest_attempt() {
2213        let server = MockServer::start().await;
2214        mount_one_queued_run(&server, &repo(), &["rm-home-win-x64"], 1, 0).await;
2215        let gateway = gateway(&server);
2216
2217        gateway
2218            .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
2219            .await
2220            .expect("both filters are mounted");
2221
2222        let requests = server.received_requests().await.expect("recorded requests");
2223        assert_eq!(requests.len(), 3);
2224
2225        let queries: Vec<String> = requests
2226            .iter()
2227            .map(|r| r.url.query().unwrap_or_default().to_string())
2228            .collect();
2229
2230        assert!(
2231            queries.iter().any(|q| q.contains("status=queued")),
2232            "the primary signal is the queued run list; sent {queries:?}"
2233        );
2234        assert!(
2235            queries.iter().any(|q| q.contains("status=in_progress")),
2236            "the safety net catches a `needs:`-gated job whose run has already \
2237             started; sent {queries:?}"
2238        );
2239        assert!(
2240            queries
2241                .iter()
2242                .any(|q| q.contains(&format!("filter={LATEST_JOBS_FILTER}"))),
2243            "`filter=all` would count every attempt of a re-run job as present \
2244             demand; sent {queries:?}"
2245        );
2246        assert!(
2247            queries
2248                .iter()
2249                .all(|q| q.contains(&format!("per_page={PER_PAGE}"))),
2250            "asking for fewer than GitHub's maximum multiplies the request count \
2251             against the budget this module projects; sent {queries:?}"
2252        );
2253    }
2254
2255    /// The queued run list is read before the in-progress one.
2256    ///
2257    /// The order is what spends the caps on the signal rather than on the safety
2258    /// net, and it is the kind of thing that is only true until somebody
2259    /// reorders a literal.
2260    #[tokio::test]
2261    async fn the_queued_run_list_is_read_before_the_in_progress_one() {
2262        let server = MockServer::start().await;
2263        mount_idle(&server, &repo()).await;
2264        let gateway = gateway(&server);
2265
2266        gateway
2267            .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
2268            .await
2269            .expect("an idle repository still answers");
2270
2271        let requests = server.received_requests().await.expect("recorded requests");
2272        let queries: Vec<String> = requests
2273            .iter()
2274            .map(|r| r.url.query().unwrap_or_default().to_string())
2275            .collect();
2276        assert_eq!(queries.len(), 2, "an idle repository lists no run's jobs");
2277        assert!(
2278            queries[0].contains("status=queued"),
2279            "the primary signal is read first; sent {queries:?}"
2280        );
2281        assert!(
2282            queries[1].contains("status=in_progress"),
2283            "the safety net is read second; sent {queries:?}"
2284        );
2285    }
2286
2287    /// An idle repository costs the two listings and nothing more.
2288    ///
2289    /// The steady-state figure the budget model prices, asserted against what
2290    /// the gateway really spends rather than left as a claim in a doc comment.
2291    #[tokio::test]
2292    async fn an_idle_repository_costs_only_the_two_run_listings() {
2293        let server = MockServer::start().await;
2294        mount_idle(&server, &repo()).await;
2295        let gateway = gateway(&server);
2296
2297        let demand = gateway
2298            .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
2299            .await
2300            .expect("an idle repository answers zero rather than failing");
2301
2302        assert_eq!(demand.total(), 0);
2303        assert!(
2304            demand.is_complete(),
2305            "zero from a repository that answered is a measurement, not a floor"
2306        );
2307        assert_eq!(gateway.requests_issued(), 2);
2308        assert!(
2309            gateway.requests_issued() <= u64::from(DEMAND_REQUESTS_PER_REPOSITORY_PER_POLL),
2310            "the idle case must sit inside the projection, which is the number `f2`'s \
2311             refusals are computed from"
2312        );
2313    }
2314
2315    /// A run whose jobs have all been dispatched is found and costs one request.
2316    ///
2317    /// The safety net's ordinary outcome, and the reason it is capped lower than
2318    /// the primary signal: on the common shape it finds nothing.
2319    #[tokio::test]
2320    async fn an_in_progress_run_with_no_queued_job_contributes_only_its_request() {
2321        let server = MockServer::start().await;
2322        mount_runs(&server, &repo(), QUEUED_RUN_STATUS, no_runs()).await;
2323        mount_runs(&server, &repo(), IN_PROGRESS_RUN_STATUS, runs_body(&[200])).await;
2324        mount_jobs(&server, &repo(), 200, jobs_body(&["rm-home-win-x64"], 0, 4)).await;
2325        let gateway = gateway(&server);
2326
2327        let demand = gateway
2328            .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
2329            .await
2330            .expect("a queued-job count");
2331
2332        assert_eq!(demand.total(), 0);
2333        assert!(demand.is_complete());
2334        assert_eq!(gateway.requests_issued(), 3);
2335    }
2336
2337    /// A run caught mid-transition appears in both listings and is counted once.
2338    ///
2339    /// The two GitHub queries are disjoint at any one instant and these are two
2340    /// requests with time between them. A run whose last waiting job is picked
2341    /// up between them is `queued` for the first and `in_progress` for the
2342    /// second — the ordinary transition, not an exotic one — so it comes back in
2343    /// both lists. Counting its jobs twice would inflate demand silently, which
2344    /// is the failure mode with no error to notice.
2345    #[tokio::test]
2346    async fn a_run_in_both_listings_is_resolved_once_and_not_counted_twice() {
2347        let server = MockServer::start().await;
2348        // The same run id in both lists, which is what the race produces.
2349        mount_runs(&server, &repo(), QUEUED_RUN_STATUS, runs_body(&[100])).await;
2350        mount_runs(&server, &repo(), IN_PROGRESS_RUN_STATUS, runs_body(&[100])).await;
2351        mount_jobs(&server, &repo(), 100, jobs_body(&["rm-home-win-x64"], 3, 0)).await;
2352        let gateway = gateway(&server);
2353
2354        let demand = gateway
2355            .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
2356            .await
2357            .expect("a queued-job count");
2358
2359        assert_eq!(
2360            demand.total(),
2361            3,
2362            "the run holds three queued jobs, and appearing in both listings does not              make it six"
2363        );
2364        assert_eq!(
2365            gateway.requests_issued(),
2366            3,
2367            "and the duplicate costs no second job listing either"
2368        );
2369    }
2370
2371    /// The job listing's id and names travel with each queued job: they are
2372    /// what a `workflow_job` webhook calls the same job.
2373    #[tokio::test]
2374    async fn each_queued_job_keeps_its_id_and_names() {
2375        let server = MockServer::start().await;
2376        mount_runs(&server, &repo(), QUEUED_RUN_STATUS, runs_body(&[200])).await;
2377        mount_runs(&server, &repo(), IN_PROGRESS_RUN_STATUS, no_runs()).await;
2378        mount_jobs(
2379            &server,
2380            &repo(),
2381            200,
2382            json!({"total_count": 3, "jobs": [
2383                {"id": 7, "status": "in_progress", "name": "running",
2384                 "workflow_name": "CI", "labels": ["rm-pool-linux"]},
2385                {"id": 8, "status": "queued", "name": "build (linux, 3.12)",
2386                 "workflow_name": "CI", "labels": ["rm-pool-linux"]},
2387                {"id": 9, "status": "queued", "name": "older", "workflow_name": null,
2388                 "labels": ["rm-pool-linux"]},
2389            ]}),
2390        )
2391        .await;
2392        let demand = gateway(&server)
2393            .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
2394            .await
2395            .expect("a reading");
2396        let identities: Vec<QueuedJobIdentity> = demand
2397            .identified_jobs_for(&repo())
2398            .filter_map(|(_, identity)| identity.cloned())
2399            .collect();
2400        assert_eq!(
2401            identities,
2402            vec![
2403                QueuedJobIdentity {
2404                    id: Some(8),
2405                    name: Some("build (linux, 3.12)".into()),
2406                    workflow: None,
2407                    run_id: Some(200),
2408                },
2409                QueuedJobIdentity {
2410                    id: Some(9),
2411                    name: Some("older".into()),
2412                    workflow: None,
2413                    run_id: Some(200),
2414                },
2415            ]
2416        );
2417        assert_eq!(demand.total(), 2);
2418    }
2419
2420    /// A job's workflow comes from its run's `path`, which unlike its title does
2421    /// not change between dispatches.
2422    #[tokio::test]
2423    async fn a_queued_job_is_named_with_its_runs_workflow_file() {
2424        let server = MockServer::start().await;
2425        mount_runs(
2426            &server,
2427            &repo(),
2428            QUEUED_RUN_STATUS,
2429            json!({
2430                "total_count": 1,
2431                "workflow_runs": [{
2432                    "id": 100,
2433                    "name": "load light [1,2] (a title)",
2434                    "path": ".github/workflows/load.yml"
2435                }]
2436            }),
2437        )
2438        .await;
2439        mount_runs(&server, &repo(), IN_PROGRESS_RUN_STATUS, no_runs()).await;
2440        mount_jobs(&server, &repo(), 100, jobs_body(&["rm-home-win-x64"], 1, 0)).await;
2441        let gateway = gateway(&server);
2442
2443        let demand = gateway
2444            .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
2445            .await
2446            .expect("read");
2447
2448        let (_, identity) = demand.identified_jobs_for(&repo()).next().expect("a job");
2449        assert_eq!(
2450            identity.and_then(|identity| identity.workflow.as_deref()),
2451            Some(".github/workflows/load.yml")
2452        );
2453        assert_eq!(
2454            demand
2455                .run_workflows_for(&repo())
2456                .and_then(|runs| runs.get(&100))
2457                .map(String::as_str),
2458            Some(".github/workflows/load.yml")
2459        );
2460    }
2461
2462    #[tokio::test]
2463    async fn one_runs_workflow_file_can_be_asked_for() {
2464        let server = MockServer::start().await;
2465        Mock::given(method("GET"))
2466            .and(path(format!(
2467                "/repos/{}/{}/actions/runs/77",
2468                repo().owner(),
2469                repo().repo()
2470            )))
2471            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2472                "id": 77,
2473                "path": ".github/workflows/ci.yml"
2474            })))
2475            .mount(&server)
2476            .await;
2477
2478        let answer = gateway(&server)
2479            .run_workflow(&repo(), 77, &CancelToken::new())
2480            .await
2481            .expect("read");
2482
2483        assert_eq!(answer.as_deref(), Some(".github/workflows/ci.yml"));
2484    }
2485
2486    /// A `needs:`-gated job whose run has already started is still demand.
2487    ///
2488    /// This is the whole reason the in-progress pass exists. Live sampling of a
2489    /// repository using this product never caught GitHub reporting a run as
2490    /// `in_progress` while one of its jobs was queued — 44 samples, 25 of them
2491    /// with a queued job — so the primary signal covers everything that was
2492    /// observed. It does not cover a job that becomes queued *after* its run
2493    /// started, which is what `needs:` produces and what this pins.
2494    #[tokio::test]
2495    async fn a_job_that_enters_the_queue_after_its_run_started_is_still_found() {
2496        let server = MockServer::start().await;
2497        mount_runs(&server, &repo(), QUEUED_RUN_STATUS, no_runs()).await;
2498        mount_runs(&server, &repo(), IN_PROGRESS_RUN_STATUS, runs_body(&[200])).await;
2499        // One job running, one released by `needs:` and now waiting.
2500        mount_jobs(&server, &repo(), 200, jobs_body(&["rm-home-win-x64"], 1, 1)).await;
2501        let gateway = gateway(&server);
2502
2503        let demand = gateway
2504            .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
2505            .await
2506            .expect("a queued-job count");
2507
2508        assert_eq!(
2509            demand.total(),
2510            1,
2511            "polling only `status=queued` runs would report zero here and the \
2512             `needs:`-gated job would wait for a machine that is idle"
2513        );
2514    }
2515
2516    // -- the wire contract, against payloads GitHub really sent -------------
2517
2518    /// The deserializers are pinned against **real** GitHub responses, not only
2519    /// against fixtures written to match them.
2520    ///
2521    /// Every other test here builds its own JSON, so all of them would keep
2522    /// passing if this module's idea of the wire format were wrong in the same
2523    /// way the fixtures are. That is the one failure a mock server cannot catch,
2524    /// and it is the failure this change was most exposed to: the previous owner
2525    /// decision deliberately read *nothing* out of a run and had no job
2526    /// endpoint at all, so `workflow_runs[].id`, `jobs[].status` and
2527    /// `jobs[].labels` are all fields this crate had never parsed before.
2528    ///
2529    /// The payloads below are verbatim excerpts captured from
2530    /// `api.github.com` on 2026-09-05 against a repository using this product,
2531    /// trimmed only by replacing the `steps` array — which is long, which this
2532    /// module does not read, and whose presence is itself part of what is being
2533    /// asserted, since a job object carries twenty-odd fields that must all be
2534    /// ignored without error.
2535    #[test]
2536    fn the_wire_shapes_parse_a_payload_github_really_sent() {
2537        // GET /repos/{o}/{r}/actions/runs/33938794901/jobs?filter=latest&per_page=100
2538        let jobs: RunJobsPage = serde_json::from_value(json!({
2539          "total_count": 5,
2540          "jobs": [
2541            {
2542              "id": 101_231_925_899_i64,
2543              "run_id": 33_938_794_901_i64,
2544              "workflow_name": "tests",
2545              "head_branch": "worktree-p1-lock-and-worktree-set",
2546              "run_url": "https://api.github.com/repos/o/r/actions/runs/33938794901",
2547              "run_attempt": 1,
2548              "node_id": "CR_kwDOS_wnss8AAAAXkeSaiw",
2549              "head_sha": "e78bc5d1865693aba030d83a2c627dd5515edf45",
2550              "url": "https://api.github.com/repos/o/r/actions/jobs/101231925899",
2551              "html_url": "https://github.com/o/r/actions/runs/33938794901/job/101231925899",
2552              "status": "completed",
2553              "conclusion": "success",
2554              "created_at": "2026-09-05T02:20:30Z",
2555              "started_at": "2026-09-05T02:26:03Z",
2556              "completed_at": "2026-09-05T02:28:44Z",
2557              "name": "scripts-tests",
2558              "steps": [],
2559              "check_run_url": "https://api.github.com/repos/o/r/check-runs/101231925899",
2560              "labels": ["self-hosted", "windows"],
2561              "runner_id": 725,
2562              "runner_name": "runner-manager-4bd32f05-088f-4b13-a59c-6900b9142aa1",
2563              "runner_group_id": 1,
2564              "runner_group_name": "Default"
2565            },
2566            {
2567              "id": 101_231_925_900_i64,
2568              "run_id": 33_938_794_901_i64,
2569              "status": "queued",
2570              "conclusion": serde_json::Value::Null,
2571              "started_at": serde_json::Value::Null,
2572              "completed_at": serde_json::Value::Null,
2573              "name": "pipeline-tests",
2574              "steps": [],
2575              "labels": ["self-hosted", "windows"],
2576              "runner_id": serde_json::Value::Null,
2577              "runner_name": "",
2578              "runner_group_name": ""
2579            },
2580            {
2581              "id": 101_231_925_901_i64,
2582              "status": "queued",
2583              "name": "scripts-tests-macos",
2584              "labels": ["self-hosted", "macOS", "rm-macmini-osx-arm64"]
2585            }
2586          ]
2587        }))
2588        .expect("the jobs page GitHub really sends must deserialize");
2589
2590        assert_eq!(
2591            jobs.jobs.len(),
2592            3,
2593            "every job is read, whatever else it carries"
2594        );
2595
2596        // What the gateway does with it: keep the queued ones, and turn each
2597        // one's `labels` array into the `RunsOn` `b1` matches.
2598        let queued: Vec<RunsOn> = jobs
2599            .jobs
2600            .into_iter()
2601            .filter(|job| job.status == QUEUED_JOB_STATUS)
2602            .map(|job| RunsOn::from_job_labels(job.labels))
2603            .collect();
2604
2605        assert_eq!(
2606            queued,
2607            vec![
2608                RunsOn::Many(vec!["self-hosted".into(), "windows".into()]),
2609                RunsOn::Many(vec![
2610                    "self-hosted".into(),
2611                    "macOS".into(),
2612                    "rm-macmini-osx-arm64".into()
2613                ]),
2614            ],
2615            "the completed job is dropped and the two queued ones keep their labels"
2616        );
2617
2618        // The Windows job requires a subset of this policy's labels, even
2619        // without its selector. The macOS job still requires missing labels.
2620        let tally = host_labels().tally(&queued);
2621        assert_eq!(tally.demand(), 1);
2622        assert_eq!(tally.not_matched, 1);
2623
2624        // GET /repos/{o}/{r}/actions/runs?status=queued&per_page=100, as an idle
2625        // repository answers it. `total_count` and an empty array, which is the
2626        // shape that must read as "nothing waiting" rather than as a failure.
2627        let idle: QueuedRunsPage = serde_json::from_value(json!({
2628            "total_count": 0,
2629            "workflow_runs": []
2630        }))
2631        .expect("an idle run listing must deserialize");
2632        assert_eq!(idle.total_count, Some(0));
2633        assert!(idle.workflow_runs.is_empty());
2634
2635        // And a busy one. A run object carries far more than the id, and the id
2636        // is the only field this module reads out of it.
2637        let busy: QueuedRunsPage = serde_json::from_value(json!({
2638            "total_count": 1,
2639            "workflow_runs": [{
2640                "id": 33_938_794_901_i64,
2641                "name": "tests",
2642                "node_id": "WFR_kwLOS_wnss8AAAAH6oSPFQ",
2643                "head_branch": "main",
2644                "head_sha": "e78bc5d1865693aba030d83a2c627dd5515edf45",
2645                "path": ".github/workflows/tests.yml",
2646                "run_number": 412,
2647                "event": "push",
2648                "status": "queued",
2649                "conclusion": serde_json::Value::Null,
2650                "workflow_id": 213_842_591_i64,
2651                "url": "https://api.github.com/repos/o/r/actions/runs/33938794901",
2652                "created_at": "2026-09-05T02:20:30Z",
2653                "updated_at": "2026-09-05T02:20:31Z"
2654            }]
2655        }))
2656        .expect("a busy run listing must deserialize");
2657        assert_eq!(
2658            busy.workflow_runs
2659                .iter()
2660                .map(|run| run.id)
2661                .collect::<Vec<_>>(),
2662            vec![33_938_794_901],
2663            "the run id is what the job listing is fetched by, and it is a u64: \
2664             GitHub's run ids passed 2^32 long ago, so a u32 here would have \
2665             wrapped on every real repository"
2666        );
2667    }
2668
2669    // -- the caps -----------------------------------------------------------
2670
2671    /// More queued runs than the cap resolves makes the answer a floor.
2672    #[tokio::test]
2673    async fn more_queued_runs_than_the_cap_report_a_floor_rather_than_a_total() {
2674        let server = MockServer::start().await;
2675        let ids: Vec<u64> = (0..(MAX_QUEUED_RUNS_PER_REPOSITORY_PER_POLL as u64 + 4))
2676            .map(|i| 100 + i)
2677            .collect();
2678        mount_runs(&server, &repo(), QUEUED_RUN_STATUS, runs_body(&ids)).await;
2679        mount_runs(&server, &repo(), IN_PROGRESS_RUN_STATUS, no_runs()).await;
2680        for id in &ids {
2681            mount_jobs(&server, &repo(), *id, jobs_body(&["rm-home-win-x64"], 1, 0)).await;
2682        }
2683        let gateway = gateway(&server);
2684
2685        let demand = gateway
2686            .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
2687            .await
2688            .expect("a bounded poll still answers");
2689
2690        assert_eq!(
2691            demand.total() as usize,
2692            MAX_QUEUED_RUNS_PER_REPOSITORY_PER_POLL,
2693            "one job resolved per run the cap admits"
2694        );
2695        assert!(
2696            !demand.is_complete(),
2697            "a count clipped by the run cap is a floor and must say so; concluding \
2698             `idle` from one would be the mistake the flag exists to prevent"
2699        );
2700        assert!(demand.is_truncated(&repo()));
2701    }
2702
2703    /// The caps bound what one repository can spend, whatever GitHub sends.
2704    ///
2705    /// The projection is a steady-state figure and the worst case is what keeps
2706    /// it honest, so the worst case has to be a real ceiling rather than an
2707    /// estimate. A repository with a hundred active runs must not spend a
2708    /// hundred requests.
2709    #[tokio::test]
2710    async fn a_repository_cannot_spend_more_than_the_documented_worst_case() {
2711        let server = MockServer::start().await;
2712        let queued: Vec<u64> = (0..40).map(|i| 100 + i).collect();
2713        let running: Vec<u64> = (0..40).map(|i| 500 + i).collect();
2714        mount_runs(&server, &repo(), QUEUED_RUN_STATUS, runs_body(&queued)).await;
2715        mount_runs(
2716            &server,
2717            &repo(),
2718            IN_PROGRESS_RUN_STATUS,
2719            runs_body(&running),
2720        )
2721        .await;
2722        // And more runs a webhook named than a reading reads by their id.
2723        let expected: Vec<u64> = (0..10).map(|i| 900 + i).collect();
2724        for id in queued.iter().chain(running.iter()).chain(expected.iter()) {
2725            mount_jobs(&server, &repo(), *id, jobs_body(&["rm-home-win-x64"], 2, 0)).await;
2726        }
2727        let gateway = gateway(&server);
2728        for id in &expected {
2729            gateway.expect_run(&repo(), *id);
2730        }
2731
2732        let demand = gateway
2733            .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
2734            .await
2735            .expect("a bounded poll still answers");
2736
2737        assert_eq!(
2738            gateway.requests_issued(),
2739            u64::from(max_demand_requests_per_repository_per_poll()),
2740            "the measured ceiling must equal the projected one, or the bound is a \
2741             sentence in a doc comment"
2742        );
2743        assert_eq!(
2744            demand.total(),
2745            2 * (MAX_QUEUED_RUNS_PER_REPOSITORY_PER_POLL
2746                + MAX_IN_PROGRESS_RUNS_PER_REPOSITORY_PER_POLL
2747                + MAX_EXPECTED_RUNS_PER_REPOSITORY_PER_POLL) as u32
2748        );
2749        assert!(!demand.is_complete());
2750    }
2751
2752    /// The queued pass is served before the in-progress one when both are over
2753    /// their caps.
2754    #[tokio::test]
2755    async fn the_primary_signal_is_resolved_before_the_safety_net() {
2756        let server = MockServer::start().await;
2757        let queued: Vec<u64> = (0..40).map(|i| 100 + i).collect();
2758        let running: Vec<u64> = (0..40).map(|i| 500 + i).collect();
2759        mount_runs(&server, &repo(), QUEUED_RUN_STATUS, runs_body(&queued)).await;
2760        mount_runs(
2761            &server,
2762            &repo(),
2763            IN_PROGRESS_RUN_STATUS,
2764            runs_body(&running),
2765        )
2766        .await;
2767        // Queued runs hold this host's work; in-progress runs hold another
2768        // host's. If the caps were spent in the other order the demand would be
2769        // made of the wrong jobs.
2770        for id in &queued {
2771            mount_jobs(&server, &repo(), *id, jobs_body(&["rm-home-win-x64"], 1, 0)).await;
2772        }
2773        for id in &running {
2774            mount_jobs(&server, &repo(), *id, jobs_body(&["ubuntu-latest"], 1, 0)).await;
2775        }
2776        let gateway = gateway(&server);
2777
2778        let demand = gateway
2779            .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
2780            .await
2781            .expect("a bounded poll still answers");
2782
2783        let tally = host_labels().tally(demand.jobs_for(&repo()));
2784        assert_eq!(
2785            tally.demand() as usize,
2786            MAX_QUEUED_RUNS_PER_REPOSITORY_PER_POLL,
2787            "the queued cap is spent in full on the primary signal"
2788        );
2789        assert_eq!(
2790            tally.not_matched as usize, MAX_IN_PROGRESS_RUNS_PER_REPOSITORY_PER_POLL,
2791            "and the safety net gets its own smaller cap, not a share of the first"
2792        );
2793    }
2794
2795    /// One run's job listing follows pages, and stops at its budget.
2796    #[tokio::test]
2797    async fn a_runs_job_listing_walks_pages_and_stops_at_its_budget() {
2798        let server = MockServer::start().await;
2799        let base = server.uri();
2800        let path_100 = jobs_path(&repo(), 100);
2801
2802        mount_runs(&server, &repo(), QUEUED_RUN_STATUS, runs_body(&[100])).await;
2803        mount_runs(&server, &repo(), IN_PROGRESS_RUN_STATUS, no_runs()).await;
2804        // Every page points at another, forever.
2805        Mock::given(method("GET"))
2806            .and(path(path_100.clone()))
2807            .respond_with(
2808                ResponseTemplate::new(200)
2809                    .set_body_json(jobs_body(&["rm-home-win-x64"], 100, 0))
2810                    .insert_header(
2811                        "link",
2812                        format!("<{base}{path_100}?page=9>; rel=\"next\"").as_str(),
2813                    ),
2814            )
2815            .mount(&server)
2816            .await;
2817        let gateway = gateway(&server);
2818
2819        let demand = gateway
2820            .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
2821            .await
2822            .expect("a bounded walk still answers");
2823
2824        assert_eq!(
2825            gateway.requests_issued() as usize,
2826            2 + MAX_JOB_PAGES_PER_RUN,
2827            "an endless `Link` chain must stop at the job page budget rather than \
2828             spending the hourly ceiling on one run"
2829        );
2830        assert_eq!(demand.total() as usize, 100 * MAX_JOB_PAGES_PER_RUN);
2831        assert!(
2832            !demand.is_complete(),
2833            "a count clipped by the page bound is a floor and must say so"
2834        );
2835        assert!(demand.is_truncated(&repo()));
2836    }
2837
2838    // -- the `total_count` tripwire ----------------------------------------
2839
2840    /// The zero-cost check that `total_count` is the *filtered* count.
2841    ///
2842    /// A repository with 3 queued runs out of 5,000 lifetime runs would answer
2843    /// `len() == 3`, no `Link`, and `total_count == 5000`. The demand number no
2844    /// longer comes from that field, so the contradiction is a `warn!` rather
2845    /// than a `debug_assert!` — but it still means GitHub is not answering the
2846    /// question this module asked, and it is free to notice.
2847    #[tokio::test]
2848    async fn a_total_count_that_disagrees_with_its_only_page_does_not_derail_the_count() {
2849        let server = MockServer::start().await;
2850        mount_runs(
2851            &server,
2852            &repo(),
2853            QUEUED_RUN_STATUS,
2854            json!({
2855                "total_count": 5_000,
2856                "workflow_runs": [{ "id": 100 }]
2857            }),
2858        )
2859        .await;
2860        mount_runs(&server, &repo(), IN_PROGRESS_RUN_STATUS, no_runs()).await;
2861        mount_jobs(&server, &repo(), 100, jobs_body(&["rm-home-win-x64"], 2, 0)).await;
2862        let gateway = gateway(&server);
2863
2864        let demand = gateway
2865            .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
2866            .await
2867            .expect("a wrong `total_count` no longer decides anything here");
2868
2869        assert_eq!(
2870            demand.total(),
2871            2,
2872            "the count comes from the jobs of the runs that were actually listed, so a \
2873             `total_count` carrying the unfiltered lifetime total cannot inflate it"
2874        );
2875        assert!(
2876            demand.is_complete(),
2877            "one listed run, no next page, and the cap not reached"
2878        );
2879    }
2880
2881    // -- organization aggregation ------------------------------------------
2882
2883    /// An organization aggregates across its installed repositories, and the
2884    /// request count grows with that repository count.
2885    #[tokio::test]
2886    async fn an_organization_aggregates_its_repositories_and_pays_per_repository() {
2887        let server = MockServer::start().await;
2888        mount_one_queued_run(&server, &repo(), &["rm-home-win-x64"], 2, 0).await;
2889        mount_one_queued_run(&server, &other_repo(), &["rm-home-win-x64"], 5, 0).await;
2890        mount_idle(&server, &third_repo()).await;
2891        let gateway = gateway(&server);
2892
2893        let two = org_scope([repo(), other_repo()]);
2894        let three = org_scope([repo(), other_repo(), third_repo()]);
2895
2896        assert_eq!(
2897            demand_requests_per_poll(&two),
2898            2 * DEMAND_REQUESTS_PER_REPOSITORY_PER_POLL,
2899            "there is no organization-wide workflow-runs endpoint, so the cost is \
2900             per repository"
2901        );
2902        assert!(
2903            demand_requests_per_poll(&three) > demand_requests_per_poll(&two),
2904            "a projection that did not grow with the repository count would understate \
2905             an organization's real spend by exactly that factor"
2906        );
2907
2908        let demand = gateway
2909            .queued_demand(&three, &CancelToken::new())
2910            .await
2911            .expect("an aggregate");
2912
2913        assert_eq!(demand.total(), 7);
2914        assert_eq!(demand.for_repository(&repo()), Some(2));
2915        assert_eq!(demand.for_repository(&other_repo()), Some(5));
2916        assert_eq!(
2917            demand.for_repository(&third_repo()),
2918            Some(0),
2919            "a repository that answered zero is present as a zero, unlike one that \
2920             could not answer at all"
2921        );
2922        assert_eq!(
2923            host_labels().tally(demand.jobs()).demand(),
2924            7,
2925            "an organization policy serves any repository in its scope, so its demand \
2926             is the whole aggregate's rather than one repository's"
2927        );
2928        assert!(
2929            gateway.requests_issued() <= u64::from(max_demand_requests_per_poll(&three)),
2930            "the measured cost must sit inside the projected ceiling, or the budget \
2931             model is a table in a document"
2932        );
2933    }
2934
2935    /// The measured cost is reported to `c3`'s budget model through the seam
2936    /// `c3` left for it, rather than inheriting the estimate.
2937    #[test]
2938    fn the_measured_demand_cost_is_reported_through_c3s_seam() {
2939        use crate::rest::DEMAND_REQUESTS_PER_REPOSITORY_PER_REFRESH;
2940
2941        let scope = org_scope([repo(), other_repo(), third_repo()]);
2942        let estimated = TargetCost::from_activity_scope(&scope);
2943        let measured = target_cost(&scope);
2944
2945        assert_eq!(
2946            DEMAND_REQUESTS_PER_REPOSITORY_PER_REFRESH, 2,
2947            "`c3`'s estimate prices the runs request and one jobs request; this module \
2948             now issues both, plus the in-progress listing and a jobs request per \
2949             additional active run, so the measured figure is higher rather than lower"
2950        );
2951        assert_ne!(
2952            measured, estimated,
2953            "the seam must actually replace the estimate; a `target_cost` that returned \
2954             `from_activity_scope` unchanged would report the estimate as measured"
2955        );
2956        // 1 inventory request + 3 repositories * (1 activity + 4 demand).
2957        assert_eq!(measured.requests_per_refresh(), 16);
2958        // 1 + 3 * (1 + 2), `c3`'s estimate.
2959        assert_eq!(estimated.requests_per_refresh(), 10);
2960        assert!(
2961            measured.requests_per_refresh() > estimated.requests_per_refresh(),
2962            "restoring the per-run job listing added requests; a measured cost that was \
2963             not higher would mean this module is not issuing them"
2964        );
2965    }
2966
2967    /// **A known gap, pinned so it stays visible.** `f2`'s refusal *decision*
2968    /// can consume the measured demand cost; the number it prints alongside the
2969    /// refusal cannot.
2970    ///
2971    /// [`crate::rest::BudgetProjection::admit`] takes the candidate
2972    /// [`TargetCost`] from its caller, so an `f2` that builds it through
2973    /// [`target_cost`] gets an admission computed from the real cost.
2974    /// [`crate::rest::BudgetProjection::max_repository_targets`] builds
2975    /// [`TargetCost::repository`] internally, which cannot see the seam and
2976    /// therefore still prices demand at `c3`'s estimate of two.
2977    ///
2978    /// The two then disagree, and an operator sees both. **The direction of the
2979    /// disagreement inverted when this module started counting jobs**, and that
2980    /// is why this test matters more than it did: the printed ceiling used to be
2981    /// conservative, and is now optimistic. It says a host fits ten repository
2982    /// targets when the cost this module really issues fits six, so an operator
2983    /// planning against the printed number can configure a host that spends more
2984    /// than the projection admitted.
2985    ///
2986    /// What keeps that from being a live budget overrun is
2987    /// `BUDGET_SHARE_DIVISOR`: the projection is compared against half of
2988    /// GitHub's hourly ceiling, so the gap is spent out of the half deliberately
2989    /// left unplanned. It is a reporting defect with a safety margin under it,
2990    /// not a correctness one — and `f1`'s `host show` prints the caveat beside
2991    /// the number.
2992    ///
2993    /// It is not fixable from this file. Both remedies — changing
2994    /// [`crate::rest::DEMAND_REQUESTS_PER_REPOSITORY_PER_REFRESH`], or giving
2995    /// `max_repository_targets` a [`TargetCost`] argument — are edits to
2996    /// `crates/github/src/rest.rs`, which `c3` owns. This test records the
2997    /// discrepancy with its arithmetic so that whoever holds that file can act on
2998    /// it, and fails if it is ever closed, at which point this test and the note
2999    /// above should go.
3000    #[test]
3001    fn the_printed_target_ceiling_still_projects_c3s_estimate() {
3002        use crate::rest::{BudgetProjection, budget_allowance};
3003        use runner_manager_domain::model::RefreshInterval;
3004
3005        let interval = RefreshInterval::default();
3006        let printed = BudgetProjection::max_repository_targets(interval);
3007        let per_hour_estimated = TargetCost::repository().requests_per_hour(interval);
3008        let per_hour_measured = TargetCost::repository()
3009            .with_demand_requests_per_repository(DEMAND_REQUESTS_PER_REPOSITORY_PER_POLL)
3010            .requests_per_hour(interval);
3011
3012        // 4 requests per refresh * 60 refreshes, against 6 * 60.
3013        assert_eq!(per_hour_estimated, 240);
3014        assert_eq!(per_hour_measured, 360);
3015        assert_eq!(
3016            printed, 10,
3017            "the printed ceiling is `04-subsystem-contracts.md`'s figure, computed from \
3018             `c3`'s estimate"
3019        );
3020        assert_eq!(
3021            budget_allowance() / per_hour_measured,
3022            6,
3023            "while the cost this module actually issues allows six"
3024        );
3025        assert!(
3026            printed > budget_allowance() / per_hour_measured,
3027            "the gap now runs in the optimistic direction: the printed ceiling is larger \
3028             than the measured cost supports. `BUDGET_SHARE_DIVISOR` is what absorbs it \
3029             -- see this test's documentation before treating the inequality as harmless"
3030        );
3031    }
3032
3033    /// One archived repository must not take down an organization's whole
3034    /// demand poll — and must not read as zero either.
3035    #[tokio::test]
3036    async fn an_organization_steps_over_a_repository_local_failure_without_reading_it_as_zero() {
3037        let server = MockServer::start().await;
3038        mount_one_queued_run(&server, &repo(), &["rm-home-win-x64"], 4, 0).await;
3039        Mock::given(method("GET"))
3040            .and(path(runs_path(&other_repo())))
3041            .respond_with(
3042                ResponseTemplate::new(404).set_body_json(json!({ "message": "Not Found" })),
3043            )
3044            .mount(&server)
3045            .await;
3046        let gateway = gateway(&server);
3047
3048        let demand = gateway
3049            .queued_demand(&org_scope([repo(), other_repo()]), &CancelToken::new())
3050            .await
3051            .expect("an aggregate steps over a repository it cannot read");
3052
3053        assert_eq!(demand.total(), 4);
3054        assert_eq!(demand.unavailable().len(), 1);
3055        assert_eq!(demand.unavailable()[0].repository, other_repo());
3056        assert_eq!(
3057            demand.for_repository(&other_repo()),
3058            None,
3059            "a repository that could not be polled is absent from the map, not present \
3060             as a zero"
3061        );
3062        assert!(
3063            demand.jobs_for(&other_repo()).is_empty(),
3064            "and its job list is empty rather than absent, so a caller tallying it \
3065             cannot accidentally read an unavailable repository as demand"
3066        );
3067        assert!(
3068            !demand.is_complete(),
3069            "an aggregate missing a repository is not a complete reading"
3070        );
3071    }
3072
3073    /// The same `404`, on a repository *target*, propagates instead.
3074    ///
3075    /// Stepping over the only repository in scope would answer demand `0` for a
3076    /// target this host cannot see at all, and `e1` would then correctly start
3077    /// no runners for the wrong reason — with no error anywhere to explain it.
3078    #[tokio::test]
3079    async fn a_repository_target_propagates_the_failure_rather_than_reporting_zero_demand() {
3080        let server = MockServer::start().await;
3081        Mock::given(method("GET"))
3082            .and(path(runs_path(&repo())))
3083            .respond_with(
3084                ResponseTemplate::new(404).set_body_json(json!({ "message": "Not Found" })),
3085            )
3086            .mount(&server)
3087            .await;
3088        let gateway = gateway(&server);
3089
3090        let error = gateway
3091            .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
3092            .await
3093            .expect_err("a scope of one has no aggregate to step over into");
3094        assert!(matches!(
3095            error,
3096            InventoryError::Github(GithubError::Status { status: 404, .. })
3097        ));
3098    }
3099
3100    /// A failure on the *job* listing is a failure of the whole poll, not a
3101    /// silently short count.
3102    ///
3103    /// The job listing is where the new requests are, so it is where a new way
3104    /// to under-count could enter: a gateway that swallowed a failed job listing
3105    /// would report the run's jobs as zero, which is indistinguishable from a
3106    /// run whose jobs have all been dispatched.
3107    #[tokio::test]
3108    async fn a_failed_job_listing_is_not_read_as_a_run_with_no_queued_jobs() {
3109        let server = MockServer::start().await;
3110        mount_runs(&server, &repo(), QUEUED_RUN_STATUS, runs_body(&[100])).await;
3111        mount_runs(&server, &repo(), IN_PROGRESS_RUN_STATUS, no_runs()).await;
3112        Mock::given(method("GET"))
3113            .and(path(jobs_path(&repo(), 100)))
3114            .respond_with(
3115                ResponseTemplate::new(500).set_body_json(json!({ "message": "Server Error" })),
3116            )
3117            .mount(&server)
3118            .await;
3119        let gateway = gateway(&server);
3120
3121        let error = gateway
3122            .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
3123            .await
3124            .expect_err("a job listing that failed is not a run with nothing queued");
3125        assert!(matches!(
3126            error,
3127            InventoryError::Github(GithubError::Status { status: 500, .. })
3128        ));
3129    }
3130
3131    /// A credential failure aborts the aggregate rather than being stepped over.
3132    ///
3133    /// A revoked token is a fact about the credential, not about the repository:
3134    /// stepping over it would report every remaining repository as unavailable
3135    /// and the total as a number, when in truth nothing can be read at all.
3136    #[tokio::test]
3137    async fn a_rate_limit_aborts_the_aggregate_rather_than_being_stepped_over() {
3138        let server = MockServer::start().await;
3139        mount_one_queued_run(&server, &repo(), &["rm-home-win-x64"], 1, 0).await;
3140        Mock::given(method("GET"))
3141            .and(path(runs_path(&other_repo())))
3142            .respond_with(
3143                ResponseTemplate::new(429)
3144                    .insert_header("retry-after", "42")
3145                    .set_body_json(json!({
3146                        "message": "You have exceeded a secondary rate limit"
3147                    })),
3148            )
3149            .mount(&server)
3150            .await;
3151        let gateway = gateway(&server);
3152
3153        let error = gateway
3154            .queued_demand(&org_scope([repo(), other_repo()]), &CancelToken::new())
3155            .await
3156            .expect_err("a rate limit is a fact about the credential, not the repository");
3157
3158        let limit = error
3159            .rate_limited()
3160            .expect("`c3`'s detector is what decides this, and it decided rate limit");
3161        assert_eq!(limit.retry_after, Some(std::time::Duration::from_secs(42)));
3162    }
3163
3164    // -- conditional polling, the shared window, and the quiet period -------
3165
3166    fn gateway_at(server: &MockServer, clock: &Arc<TestClock>) -> RestDemand {
3167        let endpoints = Endpoints::for_test_server(&server.uri()).expect("a test server base");
3168        let token = UserAccessToken::from_stored(SecretString::from(FIXTURE_TOKEN));
3169        let client =
3170            AuthenticatedClient::new(endpoints, token, Arc::clone(clock) as Arc<dyn Clock>)
3171                .expect("a client over the test server");
3172        RestDemand::new(Arc::new(client), Arc::clone(clock) as Arc<dyn Clock>)
3173    }
3174
3175    /// The account-wide quota headers GitHub puts on every response, `304`s
3176    /// included.
3177    fn with_quota(template: ResponseTemplate, remaining: u64) -> ResponseTemplate {
3178        template
3179            .insert_header("x-ratelimit-limit", "5000")
3180            .insert_header("x-ratelimit-remaining", remaining.to_string().as_str())
3181            .insert_header("x-ratelimit-used", (5000 - remaining).to_string().as_str())
3182            .insert_header("x-ratelimit-reset", "1787274000")
3183            .insert_header("x-ratelimit-resource", "core")
3184    }
3185
3186    /// One listing that answers `304` to its own tag and `200` + tag
3187    /// otherwise. Mounted conditional-first, because wiremock answers with the
3188    /// first mock whose matchers all pass.
3189    async fn mount_tagged(
3190        server: &MockServer,
3191        listing: &str,
3192        status: Option<&str>,
3193        etag: &str,
3194        body: serde_json::Value,
3195    ) {
3196        let mut not_modified = Mock::given(method("GET"))
3197            .and(path(listing.to_string()))
3198            .and(wiremock::matchers::header("if-none-match", etag));
3199        if let Some(status) = status {
3200            not_modified = not_modified.and(query_param("status", status));
3201        }
3202        not_modified
3203            .respond_with(with_quota(
3204                ResponseTemplate::new(304).insert_header("etag", etag),
3205                4_000,
3206            ))
3207            .mount(server)
3208            .await;
3209        let mut full = Mock::given(method("GET")).and(path(listing.to_string()));
3210        if let Some(status) = status {
3211            full = full.and(query_param("status", status));
3212        }
3213        full.respond_with(with_quota(
3214            ResponseTemplate::new(200)
3215                .insert_header("etag", etag)
3216                .set_body_json(body),
3217            4_000,
3218        ))
3219        .mount(server)
3220        .await;
3221    }
3222
3223    async fn mount_tagged_idle(server: &MockServer, repository: &OwnerRepo) {
3224        let runs = runs_path(repository);
3225        mount_tagged(
3226            server,
3227            &runs,
3228            Some(QUEUED_RUN_STATUS),
3229            r#"W/"queued""#,
3230            no_runs(),
3231        )
3232        .await;
3233        mount_tagged(
3234            server,
3235            &runs,
3236            Some(IN_PROGRESS_RUN_STATUS),
3237            r#"W/"in-progress""#,
3238            no_runs(),
3239        )
3240        .await;
3241    }
3242
3243    fn conditional_requests(received: &[wiremock::Request]) -> usize {
3244        received
3245            .iter()
3246            .filter(|request| request.headers.contains_key("if-none-match"))
3247            .count()
3248    }
3249
3250    /// The owner's requirement in one test: an idle repository polled again
3251    /// sends its tags back, GitHub answers `304`, and the reading is the same.
3252    #[tokio::test]
3253    async fn an_unchanged_repository_is_polled_with_its_etags_and_answered_304() {
3254        let server = MockServer::start().await;
3255        mount_tagged_idle(&server, &repo()).await;
3256        let clock = Arc::new(TestClock::default());
3257        let gateway = gateway_at(&server, &clock);
3258        let scope = ActivityScope::repository(repo());
3259
3260        let first = gateway
3261            .queued_demand(&scope, &CancelToken::new())
3262            .await
3263            .expect("a first reading");
3264        let second = gateway
3265            .queued_demand(&scope, &CancelToken::new())
3266            .await
3267            .expect("a second reading");
3268
3269        assert_eq!(first.per_repository(), second.per_repository());
3270        let received = server.received_requests().await.expect("recorded requests");
3271        assert_eq!(received.len(), 4, "two listings, twice");
3272        assert_eq!(
3273            conditional_requests(&received),
3274            2,
3275            "the second poll sends both tags back"
3276        );
3277        let traffic = gateway.client().traffic().summary(clock.now());
3278        assert_eq!((traffic.full, traffic.not_modified), (2, 2));
3279        let stats = gateway.stats();
3280        assert_eq!((stats.reads, stats.unchanged_reads), (2, 1));
3281        assert!(second.is_quiet());
3282    }
3283
3284    /// A conditional request must never hide a change: a new run on the very
3285    /// next poll is counted on that poll.
3286    #[tokio::test]
3287    async fn a_run_that_arrives_between_polls_is_counted_on_the_next_one() {
3288        let server = MockServer::start().await;
3289        let runs = runs_path(&repo());
3290        // Asked with the first tag, the queue has moved on: a full answer with
3291        // a new tag.
3292        Mock::given(method("GET"))
3293            .and(path(runs.clone()))
3294            .and(query_param("status", QUEUED_RUN_STATUS))
3295            .and(wiremock::matchers::header("if-none-match", r#"W/"empty""#))
3296            .respond_with(
3297                ResponseTemplate::new(200)
3298                    .insert_header("etag", r#"W/"one-run""#)
3299                    .set_body_json(runs_body(&[100])),
3300            )
3301            .mount(&server)
3302            .await;
3303        Mock::given(method("GET"))
3304            .and(path(runs.clone()))
3305            .and(query_param("status", QUEUED_RUN_STATUS))
3306            .respond_with(
3307                ResponseTemplate::new(200)
3308                    .insert_header("etag", r#"W/"empty""#)
3309                    .set_body_json(no_runs()),
3310            )
3311            .mount(&server)
3312            .await;
3313        mount_tagged(
3314            &server,
3315            &runs,
3316            Some(IN_PROGRESS_RUN_STATUS),
3317            r#"W/"in-progress""#,
3318            no_runs(),
3319        )
3320        .await;
3321        mount_jobs(&server, &repo(), 100, jobs_body(&["rm-home-win-x64"], 2, 0)).await;
3322        let clock = Arc::new(TestClock::default());
3323        let gateway = gateway_at(&server, &clock);
3324        let scope = ActivityScope::repository(repo());
3325
3326        let before = gateway
3327            .queued_demand(&scope, &CancelToken::new())
3328            .await
3329            .expect("an idle reading");
3330        let after = gateway
3331            .queued_demand(&scope, &CancelToken::new())
3332            .await
3333            .expect("a reading after the run arrived");
3334
3335        assert_eq!(before.total(), 0);
3336        assert_eq!(after.total(), 2, "the change is seen on the very next poll");
3337        assert!(
3338            !after.is_quiet(),
3339            "a run under way whose listing changed is activity"
3340        );
3341    }
3342
3343    /// A run stuck in the queue for a label nobody serves is still in the
3344    /// listing hours later. While its listings answer `304` it costs nothing,
3345    /// and it must not hold the target at the slower active interval.
3346    #[tokio::test]
3347    async fn a_queued_run_whose_listings_are_unchanged_is_quiet() {
3348        let server = MockServer::start().await;
3349        let runs = runs_path(&repo());
3350        mount_tagged(
3351            &server,
3352            &runs,
3353            Some(QUEUED_RUN_STATUS),
3354            r#"W/"stuck""#,
3355            runs_body(&[100]),
3356        )
3357        .await;
3358        mount_tagged(
3359            &server,
3360            &runs,
3361            Some(IN_PROGRESS_RUN_STATUS),
3362            r#"W/"in-progress""#,
3363            no_runs(),
3364        )
3365        .await;
3366        mount_tagged(
3367            &server,
3368            &jobs_path(&repo(), 100),
3369            None,
3370            r#"W/"jobs""#,
3371            jobs_body(&["ubuntu-latest"], 1, 0),
3372        )
3373        .await;
3374        let clock = Arc::new(TestClock::default());
3375        let gateway = gateway_at(&server, &clock);
3376        let scope = ActivityScope::repository(repo());
3377
3378        let first = gateway
3379            .queued_demand(&scope, &CancelToken::new())
3380            .await
3381            .expect("a first reading");
3382        let second = gateway
3383            .queued_demand(&scope, &CancelToken::new())
3384            .await
3385            .expect("a second reading");
3386
3387        assert!(
3388            !first.is_quiet(),
3389            "the first sight of a run is a change, and the target must look at it"
3390        );
3391        assert!(second.is_quiet(), "nothing changed; three 304s");
3392        assert_eq!(second.total(), 1, "the job is still counted from the cache");
3393        let received = server.received_requests().await.expect("recorded requests");
3394        assert_eq!(conditional_requests(&received), 3);
3395    }
3396
3397    /// The quota GitHub reports during the poll travels with the reading, so
3398    /// `e1` can slow down on it.
3399    #[tokio::test]
3400    async fn the_accounts_quota_travels_with_the_reading() {
3401        let server = MockServer::start().await;
3402        mount_tagged_idle(&server, &repo()).await;
3403        let clock = Arc::new(TestClock::default());
3404        let gateway = gateway_at(&server, &clock);
3405
3406        let reading = gateway
3407            .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
3408            .await
3409            .expect("a reading");
3410
3411        let quota = reading
3412            .rate_limit()
3413            .expect("every response carries the quota");
3414        assert_eq!(
3415            (quota.limit, quota.remaining, quota.used),
3416            (5000, 4000, Some(1000))
3417        );
3418    }
3419
3420    /// Two targets covering one repository make one read between them.
3421    #[tokio::test]
3422    async fn a_repository_is_read_once_for_every_target_that_covers_it() {
3423        let server = MockServer::start().await;
3424        mount_tagged_idle(&server, &repo()).await;
3425        mount_tagged_idle(&server, &other_repo()).await;
3426        let clock = Arc::new(TestClock::default());
3427        let gateway = gateway_at(&server, &clock).with_shared_readings(Duration::from_secs(10));
3428
3429        // A repository target and an organization target that contains it.
3430        gateway
3431            .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
3432            .await
3433            .expect("the repository target's reading");
3434        let organization = gateway
3435            .queued_demand(&org_scope([repo(), other_repo()]), &CancelToken::new())
3436            .await
3437            .expect("the organization target's reading");
3438
3439        assert_eq!(organization.per_repository().len(), 2);
3440        assert_eq!(
3441            gateway.requests_issued(),
3442            4,
3443            "dashboard once (2 listings) and api once (2): the organization target is \
3444             handed the repository target's reading of dashboard"
3445        );
3446        assert_eq!(gateway.stats().shared_reads, 1);
3447
3448        // Past the window the repository is read again.
3449        clock.advance_secs(10);
3450        gateway
3451            .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
3452            .await
3453            .expect("a later reading");
3454        assert_eq!(gateway.requests_issued(), 6);
3455    }
3456
3457    /// A coordinator's wake drops the shared reading, so the woken poll reads
3458    /// GitHub inside the window instead of being handed the stale one.
3459    #[tokio::test]
3460    async fn a_forgotten_reading_is_read_again_inside_the_window() {
3461        let server = MockServer::start().await;
3462        mount_tagged_idle(&server, &repo()).await;
3463        let clock = Arc::new(TestClock::default());
3464        let gateway = gateway_at(&server, &clock).with_shared_readings(Duration::from_secs(10));
3465        let scope = ActivityScope::repository(repo());
3466
3467        gateway
3468            .queued_demand(&scope, &CancelToken::new())
3469            .await
3470            .expect("a first reading");
3471        gateway
3472            .queued_demand(&scope, &CancelToken::new())
3473            .await
3474            .expect("shared");
3475        assert_eq!(gateway.requests_issued(), 2, "the second was shared");
3476
3477        gateway.forget_shared_reading(&repo());
3478        gateway
3479            .queued_demand(&scope, &CancelToken::new())
3480            .await
3481            .expect("read again");
3482        assert_eq!(gateway.requests_issued(), 4);
3483    }
3484
3485    /// Two loops asking at the same moment make one read between them.
3486    #[tokio::test]
3487    async fn concurrent_targets_wait_for_one_read_instead_of_making_two() {
3488        let server = MockServer::start().await;
3489        mount_tagged_idle(&server, &repo()).await;
3490        let clock = Arc::new(TestClock::default());
3491        let gateway =
3492            Arc::new(gateway_at(&server, &clock).with_shared_readings(Duration::from_secs(10)));
3493
3494        let scope = ActivityScope::repository(repo());
3495        let cancel = CancelToken::new();
3496        let (left, right) = tokio::join!(
3497            gateway.queued_demand(&scope, &cancel),
3498            gateway.queued_demand(&scope, &cancel),
3499        );
3500        left.expect("one reading");
3501        right.expect("the other reading");
3502        assert_eq!(gateway.requests_issued(), 2, "one read: two listings");
3503    }
3504
3505    fn secondary_limit(retry_after: Option<&str>) -> ResponseTemplate {
3506        let template = ResponseTemplate::new(429).set_body_json(json!({
3507            "message": "You have exceeded a secondary rate limit"
3508        }));
3509        match retry_after {
3510            Some(seconds) => template.insert_header("retry-after", seconds),
3511            None => template,
3512        }
3513    }
3514
3515    /// GitHub: "If the `retry-after` response header is present, you should
3516    /// not retry your request until after that many seconds has elapsed." Not
3517    /// this poll, and not any other target's poll on this host either.
3518    #[tokio::test]
3519    async fn a_secondary_limit_stops_every_demand_request_until_retry_after() {
3520        let server = MockServer::start().await;
3521        Mock::given(method("GET"))
3522            .respond_with(secondary_limit(Some("42")))
3523            .mount(&server)
3524            .await;
3525        let clock = Arc::new(TestClock::default());
3526        let gateway = gateway_at(&server, &clock);
3527
3528        let first = gateway
3529            .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
3530            .await
3531            .expect_err("a rate limit");
3532        assert_eq!(
3533            first.rate_limited().and_then(|limit| limit.retry_after),
3534            Some(Duration::from_secs(42))
3535        );
3536
3537        // Another target, half-way through the window: no request at all.
3538        clock.advance_secs(20);
3539        let held = gateway
3540            .queued_demand(
3541                &ActivityScope::repository(other_repo()),
3542                &CancelToken::new(),
3543            )
3544            .await
3545            .expect_err("still inside the quiet period");
3546        assert_eq!(
3547            held.rate_limited().and_then(|limit| limit.retry_after),
3548            Some(Duration::from_secs(22)),
3549            "the wait left, so the loop's schedule sleeps exactly that"
3550        );
3551        assert_eq!(
3552            server.received_requests().await.expect("recorded").len(),
3553            1,
3554            "a request inside the quiet period is the one GitHub said not to send"
3555        );
3556        assert_eq!(gateway.stats().suppressed, 1);
3557
3558        // At the end of the window, the next poll asks again.
3559        clock.advance_secs(22);
3560        let _ = gateway
3561            .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
3562            .await;
3563        assert_eq!(server.received_requests().await.expect("recorded").len(), 2);
3564    }
3565
3566    /// GitHub: "Otherwise, wait for at least one minute before retrying. If
3567    /// your request continues to fail due to a secondary rate limit, wait for
3568    /// an exponentially increasing amount of time between retries."
3569    #[tokio::test]
3570    async fn a_recurring_secondary_limit_doubles_the_quiet_period() {
3571        let server = MockServer::start().await;
3572        Mock::given(method("GET"))
3573            .respond_with(secondary_limit(None))
3574            .mount(&server)
3575            .await;
3576        let clock = Arc::new(TestClock::default());
3577        let gateway = gateway_at(&server, &clock);
3578        let scope = ActivityScope::repository(repo());
3579
3580        let mut waits = Vec::new();
3581        for _ in 0..4 {
3582            let error = gateway
3583                .queued_demand(&scope, &CancelToken::new())
3584                .await
3585                .expect_err("a rate limit");
3586            let wait = error
3587                .rate_limited()
3588                .and_then(|limit| limit.retry_after)
3589                .expect("a wait");
3590            waits.push(wait.as_secs());
3591            clock.advance_secs(i64::try_from(wait.as_secs()).expect("fits"));
3592        }
3593        assert_eq!(waits, [60, 120, 240, 480]);
3594    }
3595
3596    fn secondary(retry_after: Option<u64>) -> RateLimited {
3597        RateLimited {
3598            kind: RateLimitKind::Secondary,
3599            retry_after: retry_after.map(Duration::from_secs),
3600            remaining: Some(4_000),
3601            reset_unix_secs: None,
3602        }
3603    }
3604
3605    /// Every loop shares the gateway, so one secondary limit can come back on
3606    /// several requests that were in flight together. That is one episode.
3607    #[tokio::test]
3608    async fn answers_to_one_secondary_limit_in_flight_together_are_one_episode() {
3609        let server = MockServer::start().await;
3610        let clock = Arc::new(TestClock::default());
3611        let gateway = gateway_at(&server, &clock);
3612        let now = clock.now();
3613
3614        let waits: Vec<u64> = (0..4)
3615            .map(|_| {
3616                gateway
3617                    .hold_for(secondary(None), now)
3618                    .retry_after
3619                    .expect("a wait")
3620                    .as_secs()
3621            })
3622            .collect();
3623        assert_eq!(waits, [60, 60, 60, 60], "four answers, one limit");
3624
3625        clock.advance_secs(60);
3626        let again = gateway.hold_for(secondary(None), clock.now());
3627        assert_eq!(
3628            again.retry_after,
3629            Some(Duration::from_secs(120)),
3630            "an answer after the hold ended is GitHub saying \"still limited\""
3631        );
3632    }
3633
3634    /// A primary limit with a near reset must not reopen what a secondary
3635    /// limit closed.
3636    #[tokio::test]
3637    async fn a_later_answer_never_shortens_a_hold_in_force() {
3638        let server = MockServer::start().await;
3639        let clock = Arc::new(TestClock::default());
3640        let gateway = gateway_at(&server, &clock);
3641
3642        let _ = gateway.hold_for(secondary(Some(600)), clock.now());
3643        let primary = RateLimited {
3644            kind: RateLimitKind::Primary,
3645            retry_after: None,
3646            remaining: Some(0),
3647            reset_unix_secs: Some(u64::try_from(clock.now().timestamp() + 30).expect("positive")),
3648        };
3649        let after = gateway.hold_for(primary, clock.now());
3650        assert_eq!(after.retry_after, Some(Duration::from_secs(600)));
3651        clock.advance_secs(300);
3652        assert_eq!(
3653            gateway
3654                .held(clock.now())
3655                .and_then(|limit| limit.retry_after),
3656            Some(Duration::from_secs(300))
3657        );
3658    }
3659
3660    #[test]
3661    fn an_unavailable_repository_is_not_quiet() {
3662        assert!(QueuedDemand::default().is_quiet());
3663        assert!(
3664            !QueuedDemand::default()
3665                .with_unavailable(repo(), "404")
3666                .is_quiet(),
3667            "its failing listing is charged on every poll; polling it every few seconds \
3668             would multiply that"
3669        );
3670    }
3671
3672    /// A draining loop must not wait behind another loop's read.
3673    #[tokio::test]
3674    async fn a_cancelled_loop_does_not_wait_for_another_loops_read() {
3675        let server = MockServer::start().await;
3676        let clock = Arc::new(TestClock::default());
3677        let gateway = gateway_at(&server, &clock).with_shared_readings(Duration::from_secs(10));
3678        let gate = Arc::clone(
3679            gateway
3680                .shared
3681                .gates
3682                .lock()
3683                .expect("gates")
3684                .entry(repo())
3685                .or_default(),
3686        );
3687        let _another_loop = gate.lock().await;
3688        let cancel = CancelToken::new();
3689        cancel.cancel();
3690
3691        let answer = tokio::time::timeout(
3692            Duration::from_secs(5),
3693            gateway.repository_reading(&repo(), &cancel),
3694        )
3695        .await
3696        .expect("a cancelled loop returns at once instead of queueing on the gate");
3697        assert!(answer.is_err_and(|error| error.is_cancelled()));
3698    }
3699
3700    /// A success in between resets the doubling.
3701    #[tokio::test]
3702    async fn a_success_resets_the_doubling() {
3703        let server = MockServer::start().await;
3704        Mock::given(method("GET"))
3705            .respond_with(secondary_limit(None))
3706            .up_to_n_times(1)
3707            .mount(&server)
3708            .await;
3709        mount_idle(&server, &repo()).await;
3710        let clock = Arc::new(TestClock::default());
3711        let gateway = gateway_at(&server, &clock);
3712        let scope = ActivityScope::repository(repo());
3713
3714        let _ = gateway.queued_demand(&scope, &CancelToken::new()).await;
3715        clock.advance_secs(60);
3716        gateway
3717            .queued_demand(&scope, &CancelToken::new())
3718            .await
3719            .expect("GitHub answered");
3720        server.reset().await;
3721        Mock::given(method("GET"))
3722            .respond_with(secondary_limit(None))
3723            .mount(&server)
3724            .await;
3725        let again = gateway
3726            .queued_demand(&scope, &CancelToken::new())
3727            .await
3728            .expect_err("a fresh rate limit");
3729        assert_eq!(
3730            again.rate_limited().and_then(|limit| limit.retry_after),
3731            Some(Duration::from_secs(60)),
3732            "a limit after a success is a first limit again"
3733        );
3734    }
3735
3736    // -- cancellation -------------------------------------------------------
3737
3738    /// A token flipped between requests stops the poll before the next one.
3739    #[tokio::test]
3740    async fn cancellation_between_requests_stops_the_poll() {
3741        let server = MockServer::start().await;
3742        mount_one_queued_run(&server, &repo(), &["rm-home-win-x64"], 3, 0).await;
3743
3744        let gateway = gateway(&server);
3745        let cancel = CancelToken::new();
3746
3747        let first = gateway
3748            .repository_queued(&repo(), &cancel)
3749            .await
3750            .is_ok_and(|reading| reading.jobs.len() == 3);
3751        assert!(first, "the uncancelled poll reads both lists and the jobs");
3752        assert_eq!(gateway.requests_issued(), 3);
3753
3754        cancel.cancel();
3755        let error = gateway
3756            .repository_queued(&repo(), &cancel)
3757            .await
3758            .expect_err("a cancelled token opens no socket at all");
3759        assert!(error.is_cancelled());
3760        assert_eq!(
3761            gateway.requests_issued(),
3762            3,
3763            "a cancelled poll must spend nothing; the count is of requests attempted"
3764        );
3765    }
3766
3767    // -- the seam this module does not own ---------------------------------
3768
3769    /// The `runs-on` predicate is `b1`'s. This module builds its **input** and
3770    /// implements no part of the matching.
3771    ///
3772    /// # Read this before concluding the test is in the wrong file
3773    ///
3774    /// `c4`'s specification asks for "a `runs-on` table covering forms that must
3775    /// match, forms that must not, and an unresolvable expression, delegating
3776    /// the predicate to `b1`". For a while that table had no input: an owner
3777    /// decision had removed the per-run job listing, a workflow *run* carries no
3778    /// `runs-on`, and this gateway therefore produced nothing to match. That
3779    /// decision has been reversed — the module documentation says why — so the
3780    /// table has its input back and this test asserts both halves of the seam
3781    /// rather than only the delegation half.
3782    ///
3783    /// The division of labour is: `c4` reads each queued job's `labels` array
3784    /// and builds a [`RunsOn`], `b1` decides what matches, and `e1` applies the
3785    /// decision per policy. Constructing a `RunsOn` here is the correct side of
3786    /// that line; comparing labels here is not.
3787    ///
3788    /// # How the second half of that claim is asserted, and how far it reaches
3789    ///
3790    /// By reading this file's own source, because nothing done to `b1` can prove
3791    /// anything about what *this* module contains: every assertion in the body
3792    /// below would pass unchanged with a full label matcher sitting beside it.
3793    /// The scan takes the production half — everything above `#[cfg(test)]`,
3794    /// with comment lines dropped so that the module documentation may keep
3795    /// explaining the seam — and requires that it names none of `b1`'s
3796    /// *decision* vocabulary.
3797    ///
3798    /// `RunsOn` is no longer in the forbidden set, and could not be: this module
3799    /// constructs one per queued job, which is the whole point of the reversal.
3800    /// What stays forbidden is everything that would mean deciding rather than
3801    /// describing — `RoutingLabels`, whose `matches` and `tally` are the
3802    /// predicate, and `DemandTally`, which is the predicate's result. A `c4`
3803    /// that named either would be filtering, and filtering here would make the
3804    /// poll per-policy rather than per-target; the module documentation explains
3805    /// why that trade is refused.
3806    ///
3807    /// Like the needles in `nothing_in_this_crate_reserves_or_claims_a_job`, it
3808    /// is a tripwire on the obvious shape rather than a proof: a hand-rolled
3809    /// comparison of raw label strings that never names a `policy` type would
3810    /// walk past it. Stated rather than implied, for the same reason it is
3811    /// stated there.
3812    #[test]
3813    fn the_runs_on_predicate_is_b1s_and_this_module_only_feeds_it() {
3814        let labels = RoutingLabels::derive(
3815            &HostLabel::new("home").expect("a valid host label"),
3816            Os::Windows,
3817            Arch::X64,
3818        );
3819        let host = labels.host_label().as_str().to_string();
3820        assert_eq!(host, "rm-home-win-x64");
3821
3822        // Must match: the host label alone, in each documented form.
3823        for form in [
3824            RunsOn::Single(host.clone()),
3825            RunsOn::Many(vec![host.clone()]),
3826            RunsOn::Grouped {
3827                group: Some("Default".into()),
3828                labels: runner_manager_domain::policy::RunsOnLabels::One(host.clone()),
3829            },
3830        ] {
3831            assert!(
3832                labels.matches(&form).is_match(),
3833                "a job requiring only this policy's own label must match: {form:?}"
3834            );
3835        }
3836
3837        // Must not match: GitHub-hosted, and another host's label.
3838        for form in [
3839            RunsOn::Single("ubuntu-latest".into()),
3840            RunsOn::Many(vec![host.clone(), "rm-office-win-x64".into()]),
3841        ] {
3842            assert!(
3843                !labels.matches(&form).is_match(),
3844                "a job requiring a label this policy does not carry must not match: {form:?}"
3845            );
3846        }
3847
3848        // Unresolvable: an expression only GitHub can evaluate. Never demand and
3849        // never discarded.
3850        let expression = RunsOn::Single("${{ matrix.runner }}".into());
3851        assert!(matches!(
3852            labels.matches(&expression),
3853            RunsOnMatch::Unresolvable(UnresolvableRunsOn::Expression { .. })
3854        ));
3855
3856        // And the tally keeps the three apart, which is what `e1` clamps.
3857        let tally = labels.tally(&[
3858            RunsOn::Single(host.clone()),
3859            RunsOn::Single("ubuntu-latest".into()),
3860            expression,
3861        ]);
3862        assert_eq!(tally.demand(), 1);
3863        assert_eq!(tally.not_matched, 1);
3864        assert_eq!(tally.unresolvable.len(), 1);
3865        assert_eq!(tally.total_seen(), 3);
3866
3867        // The form this gateway actually builds is the array one, because that
3868        // is the shape the jobs API returns. Pinned so that a `RunsOn` built
3869        // from a job's `labels` really is a value `b1`'s predicate accepts,
3870        // rather than one that happens to compile.
3871        assert_eq!(
3872            RunsOn::from_job_labels(["self-hosted", host.as_str()]),
3873            RunsOn::Many(vec!["self-hosted".into(), host.clone()])
3874        );
3875        assert!(
3876            labels
3877                .matches(&RunsOn::from_job_labels([host.as_str()]))
3878                .is_match()
3879        );
3880        // And the direction that surprises people: a bare derived set does not
3881        // carry `self-hosted`, so the shape most workflows are written in --
3882        // `runs-on: [self-hosted, windows]` -- does **not** match a policy whose
3883        // operator never added those labels. That is `b1`'s superset rule
3884        // working as specified rather than a gap, and it is asserted here
3885        // because the job listing is what finally made it observable.
3886        assert!(
3887            !labels
3888                .matches(&RunsOn::from_job_labels(["self-hosted", host.as_str()]))
3889                .is_match(),
3890            "a job requiring `self-hosted` needs a policy carrying `self-hosted`"
3891        );
3892
3893        // And the other half of this test's title, which everything above leaves
3894        // untouched: that the predicate exercised here has no second
3895        // implementation in this file. See the documentation on this test for
3896        // what the scan covers, what it does not, and why `RunsOn` is absent
3897        // from the list.
3898        let production = this_file_above_its_tests_without_prose();
3899        for owned_by_b1 in ["RoutingLabels", "DemandTally"] {
3900            assert!(
3901                !production.contains(owned_by_b1),
3902                "the demand gateway names `{owned_by_b1}`, which belongs to `b1`: this \
3903                 module builds the predicate's input and does not apply it. Filtering \
3904                 here would make the poll per-policy rather than per-target and multiply \
3905                 its request cost by the number of policies sharing a target -- if an \
3906                 owner decision changed that, it belongs in this module's documentation \
3907                 and in this test before it belongs in the code"
3908            );
3909        }
3910        assert!(
3911            production.contains("RunsOn"),
3912            "and the gateway must still *build* a `RunsOn` per queued job; a production \
3913             half that named none would mean the job listing had been removed again and \
3914             the serial-matrix defect restored"
3915        );
3916    }
3917
3918    /// This file's source above its test module, with comment lines dropped.
3919    ///
3920    /// Two exclusions, each load-bearing. The **test module** goes because the
3921    /// tests above legitimately drive `b1`'s predicate and would accuse the file
3922    /// of owning what they are proving it delegates. The **comments** go because
3923    /// this module's documentation explains the seam at length and names the
3924    /// types to do it — a scan that forbade the explanation would get the
3925    /// explanation deleted, which is the trade
3926    /// `nothing_in_this_crate_reserves_or_claims_a_job` records making in the
3927    /// other direction.
3928    fn this_file_above_its_tests_without_prose() -> String {
3929        let (production, _) = include_str!("demand.rs")
3930            .split_once("\n#[cfg(test)]")
3931            .expect("this file has a test module, and the scan is meaningless without one");
3932        production
3933            .lines()
3934            .filter(|line| !line.trim_start().starts_with("//"))
3935            .collect::<Vec<_>>()
3936            .join("\n")
3937    }
3938
3939    /// The one normalisation both halves of the reservation scan use.
3940    ///
3941    /// Shared rather than written twice, because the defect it closes was two
3942    /// spellings of "the same" normalisation drifting apart: the haystack was
3943    /// lower-cased and the needle was not, so every needle carrying a capital —
3944    /// which is every type-shaped one — could not match a lower-cased haystack,
3945    /// and three of the seven assertions were vacuously true from the day they
3946    /// were written. One function cannot disagree with itself.
3947    fn normalise_for_scan(text: &str) -> String {
3948        text.to_ascii_lowercase().replace(['_', ' '], "")
3949    }
3950
3951    // The Actions-service call this design has no equivalent of, plus the shapes
3952    // an implementer would invent in its place. Matched case-insensitively with
3953    // `_` and spaces removed, so one needle catches the snake, camel and Pascal
3954    // spellings of an identifier at once — and so that the singular needle
3955    // catches the plural.
3956    //
3957    // # Why every needle carries `fn` or `struct`
3958    //
3959    // A bare `acquirejobs` fires on this crate's own prose, and it was written
3960    // that way first: three modules explain *why* there is no job reservation,
3961    // and each has to name the call that does not exist in order to say so. A
3962    // scan that forbids the explanation is a scan that gets the explanation
3963    // deleted, which costs more than it protects. Requiring the item keyword
3964    // narrows the needle to a *definition*, which is what the rule is actually
3965    // about.
3966    //
3967    // What this therefore does **not** catch is stated rather than implied: a
3968    // reservation reached through a trait method, a closure, or a
3969    // differently-named helper. It is a tripwire on the obvious shape, and
3970    // `c4`'s Definition of Done names review as the primary control.
3971    //
3972    // # And why every needle is spelled in halves
3973    //
3974    // `lib.rs`'s confidential-credential scan solves the same problem the same
3975    // way: a needle written out whole would appear in this file's own source and
3976    // the scan would accuse itself. Normalising
3977    // `concat!("fn ", "acquire", "_job")` leaves the quote-comma-quote between
3978    // the halves, so no needle ever appears whole in the text being scanned.
3979    const FORBIDDEN: &[&str] = &[
3980        concat!("fn ", "acquire", "_job"),
3981        concat!("fn ", "claim", "_job"),
3982        concat!("fn ", "lease", "_job"),
3983        concat!("fn ", "reserve", "_job"),
3984        // The acknowledgement this test's own title names alongside the other
3985        // three, and which the list did not actually carry. Spelled to the verb
3986        // rather than to a `_job` suffix, because the shape an implementer
3987        // reaches for acknowledges a *message* or an *assignment* as readily as
3988        // a job, and a suffixed needle would walk straight past those.
3989        concat!("fn ", "ack", "nowledge"),
3990        concat!("struct ", "Job", "Lease"),
3991        concat!("struct ", "Job", "Claim"),
3992        concat!("struct ", "Job", "Reservation"),
3993    ];
3994
3995    /// Which forbidden shape a source text names, if any.
3996    ///
3997    /// The whole-crate scan and its positive control both go through here, so a
3998    /// normalisation that cannot see a shape fails the control loudly instead of
3999    /// passing the scan silently. That is the entire point of the indirection:
4000    /// the arrangement this replaced had the control re-deriving the needle
4001    /// itself, and a copy that agrees with a buggy original proves nothing — it
4002    /// re-derived the needle the same wrong way and went green.
4003    fn forbidden_shape_in(source: &str) -> Option<&'static str> {
4004        let haystack = normalise_for_scan(source);
4005        FORBIDDEN
4006            .iter()
4007            .copied()
4008            .find(|forbidden| haystack.contains(&normalise_for_scan(forbidden)))
4009    }
4010
4011    /// No reservation, claim, lease, or acknowledgement call exists anywhere in
4012    /// this crate.
4013    ///
4014    /// `AcquireJobs` had no REST replacement, so a well-meaning implementer
4015    /// reaches for a local lease to "fix" the surplus-runner case. Three
4016    /// specifications say not to; this makes the instruction executable, over
4017    /// the whole crate rather than only this file, because the edit would most
4018    /// likely land in a new module rather than here.
4019    #[test]
4020    fn nothing_in_this_crate_reserves_or_claims_a_job() {
4021        const SOURCES: &[(&str, &str)] = &[
4022            ("conditional.rs", include_str!("conditional.rs")),
4023            ("demand.rs", include_str!("demand.rs")),
4024            ("device_flow.rs", include_str!("device_flow.rs")),
4025            ("jit.rs", include_str!("jit.rs")),
4026            ("lib.rs", include_str!("lib.rs")),
4027            ("rest.rs", include_str!("rest.rs")),
4028            ("traffic.rs", include_str!("traffic.rs")),
4029        ];
4030        // `SOURCES` is a snapshot, and a snapshot makes "anywhere in the crate"
4031        // false the moment a file is added. The walk below turns the claim back
4032        // into a claim.
4033        //
4034        // It **recurses**, and that is not incidental. `lib.rs` records the
4035        // defect a single `read_dir` produced for the same pin: a module
4036        // directory (`src/rest/mod.rs`) arrives as the entry `rest`, which does
4037        // not end in `.rs`, so a flat filter drops it and takes every file
4038        // underneath with it — leaving the pin passing while the files it exists
4039        // to cover are scanned by nothing at all.
4040        fn walk(directory: &std::path::Path, prefix: &str, found: &mut Vec<String>) {
4041            for entry in std::fs::read_dir(directory).expect("the crate's own src/ is readable") {
4042                let entry = entry.expect("a readable directory entry");
4043                let name = entry.file_name().to_string_lossy().into_owned();
4044                // `/`-joined, which is what `include_str!` takes on every
4045                // platform, so the two sides compare directly.
4046                let joined = if prefix.is_empty() {
4047                    name.clone()
4048                } else {
4049                    format!("{prefix}/{name}")
4050                };
4051                if entry.path().is_dir() {
4052                    walk(&entry.path(), &joined, found);
4053                } else if name.ends_with(".rs") {
4054                    found.push(joined);
4055                }
4056            }
4057        }
4058
4059        let mut listed: Vec<&str> = SOURCES.iter().map(|(name, _)| *name).collect();
4060        listed.sort_unstable();
4061        let mut on_disk = Vec::new();
4062        walk(
4063            std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/src")),
4064            "",
4065            &mut on_disk,
4066        );
4067        on_disk.sort_unstable();
4068        assert_eq!(
4069            listed, on_disk,
4070            "a source file was added or removed; this scan claims to cover the whole \
4071             crate and a stale list makes that claim false"
4072        );
4073
4074        for (name, source) in SOURCES {
4075            assert_eq!(
4076                forbidden_shape_in(source),
4077                None,
4078                "{name} names a forbidden shape: there is no job reservation on the REST \
4079                 path, and a local lease coordinates this host with itself and with \
4080                 nothing else"
4081            );
4082        }
4083    }
4084
4085    /// The scan above can actually see the things it forbids.
4086    ///
4087    /// A substring scan that never matches passes for the wrong reason. This
4088    /// plants the exact shapes and runs them through [`forbidden_shape_in`] —
4089    /// the same matcher the scan uses, rather than a second copy of it.
4090    ///
4091    /// # Why one planted shape was not enough
4092    ///
4093    /// It planted only the `fn` form, and that form is the one that could not
4094    /// fail: a `fn` needle is already lower-case, so it survived a
4095    /// normalisation that lower-cased the haystack and not the needle. Every
4096    /// type-shaped needle carries capitals and therefore could never match the
4097    /// lower-cased haystack — all three of them were un-catchable, and their
4098    /// three assertions vacuously true, while this control stayed green. (They
4099    /// are not written out here for the reason the list itself is spelled in
4100    /// halves: a literal would make this file fail its own gate.) Both kinds
4101    /// are planted now, and the case-shape of the plant is the property under
4102    /// test rather than an incidental detail of it.
4103    #[test]
4104    fn the_reservation_scan_catches_an_injected_reservation() {
4105        // Assembled from fragments rather than written out, because the scan
4106        // above reads this very file: a literal here would make the crate fail
4107        // its own gate, which is the trap that forced the needles to carry an
4108        // item keyword in the first place.
4109        let call = format!(
4110            "    async {} {}{}(&self) -> Result<Vec<Job>, InventoryError> {{",
4111            "fn", "acquire", "_jobs"
4112        );
4113        let item = format!("{} {}{} {{ id: u64 }}", "struct", "Job", "Lease");
4114        let acknowledgement = format!(
4115            "    async {} {}{}(&self, id: u64) {{",
4116            "fn", "ack", "nowledge_assignment"
4117        );
4118
4119        for (planted, expected) in [
4120            (&call, concat!("fn ", "acquire", "_job")),
4121            (&item, concat!("struct ", "Job", "Lease")),
4122            (&acknowledgement, concat!("fn ", "ack", "nowledge")),
4123        ] {
4124            assert_eq!(
4125                forbidden_shape_in(planted),
4126                Some(expected),
4127                "the scan's own matcher cannot see {planted:?}, so every negative \
4128                 assertion it makes about that shape is worthless"
4129            );
4130        }
4131
4132        // And the plural really is caught by the singular needle, which is the
4133        // one thing about the list above that is not obvious from reading it.
4134        assert!(
4135            call.contains("acquire_jobs"),
4136            "the planted shape is the plural the Actions-service protocol used"
4137        );
4138        // Likewise the acknowledgement needle stops at the verb, so it catches
4139        // the shapes that acknowledge something other than a job by name.
4140        assert!(
4141            acknowledgement.contains("_assignment"),
4142            "the planted acknowledgement names no job, which is why the needle \
4143             carrying a `_job` suffix would have missed it"
4144        );
4145    }
4146}