Skip to main content

runner_manager_domain/
attempt.rs

1// owner: b1-domain-core
2
3//! One runner attempt: its lifecycle, its outcome, who may act on it, and what
4//! to do about it after a restart.
5//!
6//! Two things here are easy to get subtly wrong, so they are stated before the
7//! code:
8//!
9//! **The idle exit is a normal outcome, not a failure.** Because there is no
10//! `AcquireJobs` equivalent on the REST path, nothing reserves a queued job for
11//! this host. A runner may start, find that another host took the work, and exit
12//! on its idle timeout (`03-control-flows.md`, flow 2.7). That attempt is
13//! terminal and is cleaned like any other, but [`AttemptOutcome`] records *which*
14//! terminal thing happened, because `g2` must render it distinctly from a
15//! failure and can only do so if the domain wrote the distinction down. Showing a
16//! normal surplus exit as an error sends an operator hunting a fault that does
17//! not exist.
18//!
19//! **A `busy` attempt is never cleaned to free capacity.** Scale-down reclaims a
20//! slot when an attempt reaches a terminal state and at no other time
21//! (`04-subsystem-contracts.md`: "`busy` cannot transition to cleanup due to a
22//! scale-down request"). [`RunnerAttempt::clean`] refuses it by name rather than
23//! by a generic transition error, so the refusal is legible in a log.
24
25use std::fmt;
26use std::path::{Path, PathBuf};
27
28use serde::{Deserialize, Serialize};
29
30use crate::model::{AttemptId, Clock, Elapsed, HostId, PolicyId, Timestamp};
31use crate::policy::ScalePolicy;
32use crate::workspace::{AttemptWorkspace, WorkspaceError, WorkspaceKind};
33
34// ---------------------------------------------------------------------------
35// Errors
36// ---------------------------------------------------------------------------
37
38#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
39pub enum AttemptError {
40    #[error("{to} is not a legal transition from {from}")]
41    IllegalTransition {
42        from: AttemptState,
43        to: AttemptState,
44    },
45
46    /// A stored workspace kind and slot that cannot describe one allocation, so
47    /// the legal cleanup algorithm for the journalled path is undecidable.
48    #[error(transparent)]
49    Workspace(#[from] WorkspaceError),
50
51    #[error(
52        "a busy attempt must not be cleaned; capacity is reclaimed only when an \
53         attempt reaches a terminal state, never by stopping a runner that is \
54         executing a job"
55    )]
56    BusyCannotBeCleaned,
57
58    #[error("the outcome {outcome:?} cannot be reached from {from}")]
59    OutcomeUnreachable {
60        from: AttemptState,
61        outcome: AttemptOutcome,
62    },
63
64    #[error("{state} is a terminal state and requires an outcome, but none was recorded")]
65    TerminalWithoutOutcome { state: AttemptState },
66
67    #[error("{state} is not terminal, so it must not carry the outcome {outcome:?}")]
68    NonTerminalWithOutcome {
69        state: AttemptState,
70        outcome: AttemptOutcome,
71    },
72
73    #[error("the recorded outcome {outcome:?} does not belong to the recorded state {state}")]
74    OutcomeStateMismatch {
75        state: AttemptState,
76        outcome: AttemptOutcome,
77    },
78
79    #[error("{state} is a terminal state and requires a terminal_at, but none was recorded")]
80    TerminalWithoutTimestamp { state: AttemptState },
81
82    #[error("{state} is not terminal, so it must not carry a terminal_at")]
83    NonTerminalWithTimestamp { state: AttemptState },
84
85    #[error(
86        "{state} carries {field} at {found}, which is before its created_at of \
87         {created_at}; an attempt cannot have changed state or concluded before \
88         it was allocated"
89    )]
90    TimestampsOutOfOrder {
91        state: AttemptState,
92        /// Which of the two orderable timestamps is out of order.
93        field: &'static str,
94        created_at: Timestamp,
95        found: Timestamp,
96    },
97}
98
99/// Ownership rule 2: "A host agent may act only on attempts persisted under its
100/// `host_id`."
101#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
102pub enum OwnershipError {
103    #[error(
104        "attempt {attempt} belongs to policy {attempt_policy}, but was checked \
105         against policy {policy}"
106    )]
107    PolicyMismatch {
108        attempt: AttemptId,
109        attempt_policy: PolicyId,
110        policy: PolicyId,
111    },
112
113    #[error(
114        "policy {policy} belongs to host {owner}; this agent runs on host \
115         {agent} and must not act on its attempts"
116    )]
117    ForeignHost {
118        policy: PolicyId,
119        owner: HostId,
120        agent: HostId,
121    },
122}
123
124// ---------------------------------------------------------------------------
125// AttemptState
126// ---------------------------------------------------------------------------
127
128/// The runner-attempt lifecycle, exactly as `04-subsystem-contracts.md` draws
129/// it after its 2026-08-21 amendment:
130///
131/// ```text
132/// allocated -> jit_received -> starting -> idle | busy
133/// idle -> busy
134/// allocated | jit_received | starting -> failed | orphaned
135/// idle | busy -> finished | failed | orphaned
136/// finished | failed | orphaned -> cleaned
137/// ```
138///
139/// `idle` means the runner process is registered and awaiting its single job
140/// assignment. It is short-lived and is **not** an idle persistent runner — this
141/// product has none, by D7.
142///
143/// **Every transition outside that diagram is rejected**, which `b1`'s
144/// Definition of Done requires. `cleaned` is absorbing, which is correct and
145/// intended.
146///
147/// **The last two edge sets were added by amendment, and the reasons matter to
148/// anyone reading this state machine.** `b1` first implemented the original
149/// diagram faithfully and surfaced its gaps as an explicit
150/// `NoLegalTransition` decision rather than inventing edges; the amendment
151/// closed them at the design level, and that decision value is gone with them:
152///
153/// * **`idle -> busy`.** `e3`'s Scope step 4 moves an attempt through
154///   `jit_received`, `starting`, `idle`, `busy` *in sequence*, and the
155///   definition of `idle` above describes a state that by construction precedes
156///   a job. Without this edge a runner observed idle and then assigned a job had
157///   nowhere legal to go.
158/// * **Terminal edges out of the three pre-registration states.** An attempt
159///   counts against host capacity for exactly as long as it is non-terminal
160///   ([`Self::counts_against_capacity`]), so an attempt that could not reach a
161///   terminal state **held a host capacity slot permanently**: two failed JIT
162///   requests on a `host_capacity: 2` host wedged that host into starting zero
163///   runners, with no error state and no cleanup path. `orphaned` is included
164///   for the restart case, where a pre-registration attempt is found after the
165///   agent restarts.
166///
167/// Those edges are also what make seven of the nine [`FailureReason`] variants
168/// reachable at all — `JitRequestFailed`, `JitExpired`,
169/// `RunnerPackageUnverified`, `RunnerVersionRejected`, `ProcessStartFailed`,
170/// `RegistrationTimedOut` and `TerminatedAfterRegistrationTimeout` each occur at
171/// a pre-registration state. `03-control-flows.md` flow 2 names the first five
172/// as conditions the agent must record; the last two are named by no document
173/// and are this crate's own, added because [`recovery_decision`] needs to say
174/// "alive, past its deadline, still unregistered" without calling it a crash,
175/// and because `e3` then needs to say what became of that runner without
176/// calling a process it stopped itself either a crash or still running.
177#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
178#[serde(rename_all = "snake_case")]
179pub enum AttemptState {
180    Allocated,
181    JitReceived,
182    Starting,
183    Idle,
184    Busy,
185    Finished,
186    Failed,
187    Orphaned,
188    Cleaned,
189}
190
191impl AttemptState {
192    pub const ALL: [AttemptState; 9] = [
193        AttemptState::Allocated,
194        AttemptState::JitReceived,
195        AttemptState::Starting,
196        AttemptState::Idle,
197        AttemptState::Busy,
198        AttemptState::Finished,
199        AttemptState::Failed,
200        AttemptState::Orphaned,
201        AttemptState::Cleaned,
202    ];
203
204    /// The complete legal transition list. A self-transition is not in it.
205    pub const LEGAL: &'static [(AttemptState, AttemptState)] = &[
206        // `allocated -> jit_received -> starting -> idle | busy`.
207        (AttemptState::Allocated, AttemptState::JitReceived),
208        (AttemptState::JitReceived, AttemptState::Starting),
209        (AttemptState::Starting, AttemptState::Idle),
210        (AttemptState::Starting, AttemptState::Busy),
211        // `idle -> busy`.
212        (AttemptState::Idle, AttemptState::Busy),
213        // `allocated | jit_received | starting -> failed | orphaned`.
214        (AttemptState::Allocated, AttemptState::Failed),
215        (AttemptState::Allocated, AttemptState::Orphaned),
216        (AttemptState::JitReceived, AttemptState::Failed),
217        (AttemptState::JitReceived, AttemptState::Orphaned),
218        (AttemptState::Starting, AttemptState::Failed),
219        (AttemptState::Starting, AttemptState::Orphaned),
220        // `idle | busy -> finished | failed | orphaned`.
221        (AttemptState::Idle, AttemptState::Finished),
222        (AttemptState::Idle, AttemptState::Failed),
223        (AttemptState::Idle, AttemptState::Orphaned),
224        (AttemptState::Busy, AttemptState::Finished),
225        (AttemptState::Busy, AttemptState::Failed),
226        (AttemptState::Busy, AttemptState::Orphaned),
227        // `finished | failed | orphaned -> cleaned`.
228        (AttemptState::Finished, AttemptState::Cleaned),
229        (AttemptState::Failed, AttemptState::Cleaned),
230        (AttemptState::Orphaned, AttemptState::Cleaned),
231    ];
232
233    /// The five states an attempt can still be concluded from: every
234    /// non-terminal state. After the amendment this is exactly
235    /// "not [`Self::is_terminal`]", which is what closed the permanently-held
236    /// capacity slot — but it is written out rather than derived, because the
237    /// two are only equal while the diagram gives every live state a terminal
238    /// edge, and that equality is a property worth failing loudly on.
239    pub const CONCLUDABLE_FROM: &'static [AttemptState] = &[
240        AttemptState::Allocated,
241        AttemptState::JitReceived,
242        AttemptState::Starting,
243        AttemptState::Idle,
244        AttemptState::Busy,
245    ];
246
247    #[must_use]
248    pub fn can_transition_to(self, next: AttemptState) -> bool {
249        Self::LEGAL.contains(&(self, next))
250    }
251
252    /// Terminal states, at which capacity is reclaimed.
253    ///
254    /// `e1`: "capacity is reclaimed only when an attempt reaches a terminal
255    /// state." `cleaned` is included because it follows a terminal state.
256    #[must_use]
257    pub const fn is_terminal(self) -> bool {
258        matches!(
259            self,
260            AttemptState::Finished
261                | AttemptState::Failed
262                | AttemptState::Orphaned
263                | AttemptState::Cleaned
264        )
265    }
266
267    /// Whether this attempt still occupies one of the host's capacity slots.
268    ///
269    /// This is the term the reconciliation formula subtracts, and getting it
270    /// wrong is silent: counting a terminal attempt starves the host, and failing
271    /// to count a `starting` one starts a second runner for a job already being
272    /// served.
273    #[must_use]
274    pub const fn counts_against_capacity(self) -> bool {
275        !self.is_terminal()
276    }
277
278    /// The three terminal states that require an outcome before `cleaned`.
279    #[must_use]
280    pub const fn is_concluded(self) -> bool {
281        matches!(
282            self,
283            AttemptState::Finished | AttemptState::Failed | AttemptState::Orphaned
284        )
285    }
286}
287
288impl fmt::Display for AttemptState {
289    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
290        f.write_str(match self {
291            AttemptState::Allocated => "allocated",
292            AttemptState::JitReceived => "jit_received",
293            AttemptState::Starting => "starting",
294            AttemptState::Idle => "idle",
295            AttemptState::Busy => "busy",
296            AttemptState::Finished => "finished",
297            AttemptState::Failed => "failed",
298            AttemptState::Orphaned => "orphaned",
299            AttemptState::Cleaned => "cleaned",
300        })
301    }
302}
303
304// ---------------------------------------------------------------------------
305// Outcome
306// ---------------------------------------------------------------------------
307
308/// Why an attempt failed.
309///
310/// `Other` exists so `e3` is not blocked by a reason this task did not
311/// anticipate; `crates/domain/src/attempt.rs` belongs to `b1` and `e3` cannot
312/// extend this enum itself.
313///
314/// **Nothing that reaches this type may contain a credential.** It is written to
315/// the journal by `b2` and rendered by `g2`, and `07-security.md`'s log scan runs
316/// over both. A JIT blob, an `Authorization` header, or a token in an `Other`
317/// string would defeat that gate from inside the domain, where the redacting log
318/// sink (`d1`) never gets a chance to see it.
319#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
320#[serde(rename_all = "snake_case")]
321pub enum FailureReason {
322    /// `generate-jitconfig` did not return a configuration.
323    JitRequestFailed,
324    /// The configuration was never claimed and expired (flow 4.4).
325    JitExpired,
326    /// The runner package's published checksum was absent or did not match
327    /// (`05-infrastructure.md`: the agent fails closed).
328    RunnerPackageUnverified,
329    /// GitHub rejects runners more than 30 days behind the latest release
330    /// (`01-current-architecture.md`, edge case 7). Terminal and
331    /// operator-actionable, never retried.
332    RunnerVersionRejected,
333    /// The child process could not be spawned.
334    ProcessStartFailed,
335    /// The child process exited before it could do its one job.
336    ///
337    /// **Only for a process that is actually gone.** A runner still running past
338    /// its startup deadline is [`Self::RegistrationTimedOut`], not this: `g2`
339    /// renders these strings to an operator, and telling one that a process
340    /// "exited unexpectedly" while it is visible in Task Manager spends the
341    /// credibility of every other message this product prints.
342    ProcessExitedUnexpectedly,
343    /// The runner process is up but never registered with GitHub inside its
344    /// startup window.
345    ///
346    /// Split from [`Self::ProcessExitedUnexpectedly`] because it is accurate and
347    /// because it points an operator somewhere else entirely. A process that
348    /// exited is a crash to investigate — logs, exit code, a corrupt runner
349    /// package. A process that is alive and unregistered has almost always
350    /// failed to *reach* GitHub: a proxy, a firewall, a DNS answer, an expired
351    /// or wrong-scoped configuration. Those are configuration and networking
352    /// fixes, and an operator sent to the wrong one of the two loses the time
353    /// this distinction exists to save.
354    RegistrationTimedOut,
355    /// The agent stopped a runner process that had not registered inside its
356    /// startup window.
357    ///
358    /// **The dead-process counterpart of [`Self::RegistrationTimedOut`], and it
359    /// exists because that one cannot be reused here.** By the time this reason
360    /// is recorded the process is gone — the agent signalled it — so rendering
361    /// "the runner process is running but did not register" would tell an
362    /// operator a process is up that they can see is not. That is the same false
363    /// liveness claim [`Self::ProcessExitedUnexpectedly`]'s documentation says
364    /// spends the credibility of every other message this product prints, and
365    /// `tests::no_decision_calls_a_dead_process_live` is what holds the line.
366    ///
367    /// **It points where [`Self::RegistrationTimedOut`] points, not where
368    /// [`Self::ProcessExitedUnexpectedly`] points.** The runner never reached
369    /// GitHub — a proxy, a firewall, a DNS answer, an expired or wrong-scoped
370    /// configuration — and the exit is the agent's own doing rather than
371    /// evidence of a crash. An operator sent to logs and exit codes for this is
372    /// investigating the wrong machine.
373    ///
374    /// **Who records it.** `e3`, and only `e3`. [`recovery_decision`] cannot
375    /// derive it: a process this agent killed and a process that crashed on its
376    /// own present the *same* [`RecoveryObservation`], so the distinguishing
377    /// fact has to be journalled as terminate-intent before the signal is sent
378    /// and read back afterwards. See [`RecoveryDecision::Terminate`] for the
379    /// window that obligation closes.
380    TerminatedAfterRegistrationTimeout,
381    /// Anything else. Must carry no credential.
382    Other(String),
383}
384
385impl FailureReason {
386    /// One value of every variant, the counterpart of [`AttemptState::ALL`].
387    ///
388    /// `Other`'s detail is empty because what a caller enumerates is the
389    /// *variant*; no consumer should read the string out of this constant.
390    ///
391    /// **This list is hand-written, and what keeps it honest is not its own
392    /// length.** A length written as `9` next to nine elements asserts
393    /// nothing — that was the defect in the assertion this constant replaced.
394    /// What catches a new variant is the exhaustive, wildcard-free `match` in
395    /// `tests::earliest_state_producing`, which stops the test target compiling
396    /// the moment one is added and so puts the author in front of this list.
397    ///
398    /// **The residual gap, measured rather than assumed.** An author who adds a
399    /// variant, writes its `Display` arm and its `earliest_state_producing` arm,
400    /// and then adds it to *neither* this constant nor the test's `cases` table,
401    /// gets a green suite with the variant untested. Adding it to exactly one of
402    /// the two fails the length check; adding it to neither does not.
403    ///
404    /// Re-measured when `TerminatedAfterRegistrationTimeout` was added, because
405    /// a gap described once and never re-run is a gap nobody knows still exists.
406    /// With the variant declared and both match arms written but neither list
407    /// touched, `cargo test -p runner-manager-domain` was green — its lib target
408    /// reported `128 passed; 0 failed` — with the ninth variant unreachable and
409    /// untested. Adding it to this constant alone then failed the length check
410    /// with `left: 8 / right: 9`.
411    ///
412    /// **And the compiler never points at this constant.** A `const` array is
413    /// unaffected by a new variant, so nothing here errors. What stops the
414    /// author is `Display::fmt`'s match (`E0004`) and then
415    /// `tests::earliest_state_producing`'s, and neither of those mentions this
416    /// list — which is why a note pointing back here sits at each of those two
417    /// match sites, where the author is actually standing.
418    ///
419    /// **This is closable in stable Rust, and is hand-written anyway.** A local
420    /// `macro_rules!` that declares the enum and emits `ALL` from the same
421    /// variant list needs no dependency and no unstable feature
422    /// (`std::mem::variant_count` is unstable, but a declarative macro is not
423    /// the same thing). It is not used here because every variant of this enum
424    /// carries several paragraphs of its own documentation explaining what an
425    /// operator should do about it, and variants declared inside a macro
426    /// invocation are markedly worse to read and to `rustdoc`. That is a
427    /// legibility trade, deliberately taken — not an impossibility. If the
428    /// documentation ever thins out, the macro is the better answer.
429    pub const ALL: [FailureReason; 9] = [
430        FailureReason::JitRequestFailed,
431        FailureReason::JitExpired,
432        FailureReason::RunnerPackageUnverified,
433        FailureReason::RunnerVersionRejected,
434        FailureReason::ProcessStartFailed,
435        FailureReason::ProcessExitedUnexpectedly,
436        FailureReason::RegistrationTimedOut,
437        FailureReason::TerminatedAfterRegistrationTimeout,
438        FailureReason::Other(String::new()),
439    ];
440}
441
442impl fmt::Display for FailureReason {
443    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
444        // Adding a variant? This `E0004` is where the compiler stops you, and it
445        // is the *only* place it does for `FailureReason::ALL`, which is a const
446        // array and errors nowhere. Add the variant to `ALL` and to the `cases`
447        // table in `tests::all_failure_reasons_are_reachable_from_the_state_that
448        // _produces_them` as well, or it ships untested and unreachable.
449        match self {
450            FailureReason::JitRequestFailed => f.write_str("the JIT configuration request failed"),
451            FailureReason::JitExpired => f.write_str("the JIT configuration expired unclaimed"),
452            FailureReason::RunnerPackageUnverified => {
453                f.write_str("the runner package could not be verified")
454            }
455            FailureReason::RunnerVersionRejected => {
456                f.write_str("GitHub rejected the runner version")
457            }
458            FailureReason::ProcessStartFailed => f.write_str("the runner process failed to start"),
459            FailureReason::ProcessExitedUnexpectedly => {
460                f.write_str("the runner process exited unexpectedly")
461            }
462            FailureReason::RegistrationTimedOut => f.write_str(
463                "the runner process is running but did not register with GitHub \
464                 before its startup deadline",
465            ),
466            // Past tense, and no claim that anything is still running: by the
467            // time this is recorded the agent has signalled the process and the
468            // operator can see it is gone. `tests::a_terminated_runner_is_never
469            // _described_as_running` pins that.
470            FailureReason::TerminatedAfterRegistrationTimeout => f.write_str(
471                "the agent stopped the runner process after it failed to \
472                 register with GitHub before its startup deadline",
473            ),
474            FailureReason::Other(detail) => write!(f, "{detail}"),
475        }
476    }
477}
478
479/// What terminally happened to an attempt.
480///
481/// The state and the outcome are not two independent fields that a caller has to
482/// keep consistent: [`AttemptOutcome::terminal_state`] derives the state from the
483/// outcome, and [`RunnerAttempt::conclude`] is the only way to set either. A
484/// `failed` attempt whose outcome says it ran a job is therefore not a bug this
485/// code can have.
486#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
487#[serde(tag = "outcome", rename_all = "snake_case")]
488pub enum AttemptOutcome {
489    /// The runner accepted its one job and the job ended. This says the *runner*
490    /// finished, never that the workflow succeeded — GitHub remains the source of
491    /// truth for workflow outcome (`03-control-flows.md`, flow 2, Failure).
492    CompletedJob,
493    /// The surplus case. The runner registered, no job arrived, and it exited on
494    /// its idle timeout (flow 2.7). Normal, bounded, and **not** a failure.
495    ExitedIdleWithoutWork,
496    /// The attempt failed.
497    Failed { reason: FailureReason },
498    /// Supervision was lost: the process is gone and the attempt could not be
499    /// reconciled with GitHub (`e3`, restart recovery).
500    Orphaned,
501}
502
503impl AttemptOutcome {
504    #[must_use]
505    pub fn failed(reason: FailureReason) -> Self {
506        Self::Failed { reason }
507    }
508
509    /// The one terminal state this outcome corresponds to.
510    #[must_use]
511    pub const fn terminal_state(&self) -> AttemptState {
512        match self {
513            AttemptOutcome::CompletedJob | AttemptOutcome::ExitedIdleWithoutWork => {
514                AttemptState::Finished
515            }
516            AttemptOutcome::Failed { .. } => AttemptState::Failed,
517            AttemptOutcome::Orphaned => AttemptState::Orphaned,
518        }
519    }
520
521    /// True for the outcomes an operator should be told to look at.
522    ///
523    /// `g2` renders this differently from [`Self::is_idle_exit`], and `e1`'s
524    /// Definition of Done requires that a surplus attempt "is not reported as a
525    /// failure".
526    #[must_use]
527    pub const fn is_failure(&self) -> bool {
528        matches!(
529            self,
530            AttemptOutcome::Failed { .. } | AttemptOutcome::Orphaned
531        )
532    }
533
534    /// True only for the surplus case.
535    #[must_use]
536    pub const fn is_idle_exit(&self) -> bool {
537        matches!(self, AttemptOutcome::ExitedIdleWithoutWork)
538    }
539
540    /// True only when this runner actually took a job.
541    #[must_use]
542    pub const fn ran_a_job(&self) -> bool {
543        matches!(self, AttemptOutcome::CompletedJob)
544    }
545
546    /// The states an attempt must be in for this outcome to be reachable.
547    ///
548    /// Two of the four outcomes are narrower than the diagram's terminal edges,
549    /// and deliberately so: `finished` is reachable from both `idle` and `busy`,
550    /// but only one of them can have produced each of the two outcomes that lead
551    /// there. Failure and orphaning are as wide as the diagram — every live
552    /// state has a `-> failed | orphaned` edge since the amendment, and
553    /// narrowing them per [`FailureReason`] here would invent product rules no
554    /// document states.
555    const fn required_from(&self) -> &'static [AttemptState] {
556        match self {
557            // Only a runner that was assigned a job can have run one.
558            AttemptOutcome::CompletedJob => &[AttemptState::Busy],
559            // Only a runner that was registered and waiting can have exited idle.
560            AttemptOutcome::ExitedIdleWithoutWork => &[AttemptState::Idle],
561            AttemptOutcome::Failed { .. } | AttemptOutcome::Orphaned => {
562                AttemptState::CONCLUDABLE_FROM
563            }
564        }
565    }
566}
567
568impl fmt::Display for AttemptOutcome {
569    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
570        match self {
571            AttemptOutcome::CompletedJob => f.write_str("ran a job"),
572            AttemptOutcome::ExitedIdleWithoutWork => f.write_str("exited idle without work"),
573            AttemptOutcome::Failed { reason } => write!(f, "failed: {reason}"),
574            AttemptOutcome::Orphaned => f.write_str("orphaned"),
575        }
576    }
577}
578
579// ---------------------------------------------------------------------------
580// RunnerAttempt
581// ---------------------------------------------------------------------------
582
583/// One ephemeral runner, from directory allocation to cleanup.
584///
585/// The fields `04-subsystem-contracts.md` names are all here. Two are not in that
586/// list and are added deliberately:
587///
588/// * `outcome`, because `b1`'s Scope requires the attempt to "carry an outcome
589///   distinguishing 'ran a job' from 'exited idle without work'".
590/// * `last_state_change_at`, because recovery decisions measure elapsed time in
591///   the *current* state — an idle timeout runs from entering `idle`, not from
592///   `created_at` — and `b1`'s Definition of Done requires those decisions to be
593///   testable against a fake clock.
594///
595/// `b2` persists both.
596#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
597pub struct RunnerAttempt {
598    pub id: AttemptId,
599    pub policy_id: PolicyId,
600    github_runner_id: Option<u64>,
601    state: AttemptState,
602    outcome: Option<AttemptOutcome>,
603    process_id: Option<u32>,
604    runtime_path: PathBuf,
605    /// Which cleanup algorithm this attempt's directory is entitled to, and the
606    /// slot it leases if any.
607    ///
608    /// Immutable after allocation, by `02-target-architecture.md`: "The
609    /// workspace kind and slot number tell recovery which cleanup algorithm is
610    /// legal. Neither may change after allocation." Private with no setter is
611    /// how that is enforced — a mutable kind would let a running attempt convert
612    /// a disposable directory into a retained one, which is exactly the
613    /// two-job contamination path `04-security-recovery.md` measures.
614    workspace: AttemptWorkspace,
615    pub created_at: Timestamp,
616    terminal_at: Option<Timestamp>,
617    last_state_change_at: Timestamp,
618}
619
620/// Every stored column of one attempt, named rather than positional.
621///
622/// **Why this is a struct.** [`RunnerAttempt::from_persisted`] took ten
623/// positional arguments, and two of them — `created_at` and
624/// `last_state_change_at` — are both `Timestamp`. Transposing them type-checked,
625/// compiled, and silently reverted `last_state_change_at` to `created_at`, which
626/// is precisely the bug that field exists to prevent: every recovery timeout
627/// would then have measured from allocation rather than from the current state,
628/// so a long-running `busy` attempt would be read as a stuck one. `terminal_at`
629/// is a third `Option<Timestamp>` in the same list.
630///
631/// `b2` maps database columns onto this type. With a struct that mapping is
632/// checked by name at compile time; positionally it was checked by nothing.
633///
634/// **That guarantee covers the Rust side of the mapping and no more.** It is
635/// the *field* names that the compiler checks, not the column names they are
636/// read from: `PersistedAttempt { created_at: row.get("last_state_change_at")?,
637/// … }` compiles exactly as happily as the correct version, and reintroduces the
638/// very transposition described above. `b2` still owes a test that loads a row
639/// whose columns hold distinguishable values and asserts each landed in the
640/// field of the same name; this type does not supply one.
641///
642/// Construct it with a struct literal so every field is written down at the call
643/// site — that is the whole point, and a builder or a `Default` would give the
644/// omission back.
645#[derive(Debug, Clone, PartialEq, Eq)]
646pub struct PersistedAttempt {
647    pub id: AttemptId,
648    pub policy_id: PolicyId,
649    pub github_runner_id: Option<u64>,
650    pub state: AttemptState,
651    pub outcome: Option<AttemptOutcome>,
652    pub process_id: Option<u32>,
653    pub runtime_path: PathBuf,
654    /// `ephemeral` or `persistent`, stored beside the slot below.
655    pub workspace_kind: WorkspaceKind,
656    /// The leased slot: `Some` exactly when `workspace_kind` is `persistent`,
657    /// and positive. It is a raw `u16` here rather than a `NonZeroU16` so that
658    /// the `0` a hand-edited row can hold is refused by
659    /// [`AttemptWorkspace::from_persisted`] instead of being unrepresentable at
660    /// the column boundary and panicking somewhere else.
661    pub workspace_slot: Option<u16>,
662    /// When the runtime directory was allocated. Never moves.
663    pub created_at: Timestamp,
664    /// Set when, and only when, the attempt concluded.
665    pub terminal_at: Option<Timestamp>,
666    /// When the attempt entered its **current** state. Recovery timeouts run
667    /// from here, not from `created_at`.
668    pub last_state_change_at: Timestamp,
669}
670
671impl RunnerAttempt {
672    /// Every stored column of this attempt, for `b2` to write back.
673    ///
674    /// The exact inverse of [`Self::from_persisted`], so a round trip through
675    /// the journal is expressible without reaching for a field accessor per
676    /// column and without this type exposing its private fields for writing.
677    #[must_use]
678    pub fn to_persisted(&self) -> PersistedAttempt {
679        PersistedAttempt {
680            id: self.id,
681            policy_id: self.policy_id,
682            github_runner_id: self.github_runner_id,
683            state: self.state,
684            outcome: self.outcome.clone(),
685            process_id: self.process_id,
686            runtime_path: self.runtime_path.clone(),
687            workspace_kind: self.workspace.kind(),
688            workspace_slot: self.workspace.slot_number(),
689            created_at: self.created_at,
690            terminal_at: self.terminal_at,
691            last_state_change_at: self.last_state_change_at,
692        }
693    }
694
695    /// The first step of `e3`'s per-attempt flow: a runtime directory is
696    /// allocated and journalled **before** anything remote happens, so a crash
697    /// leaves a recoverable trace rather than an invisible one.
698    /// D3 keeps this the disposable path: an attempt allocated through it is
699    /// [`AttemptWorkspace::Ephemeral`], so every existing caller and test goes on
700    /// producing the behaviour it produced before persistent slots existed.
701    /// [`Self::allocate_in`] is the one that leases a slot.
702    #[must_use]
703    pub fn allocate(
704        id: AttemptId,
705        policy_id: PolicyId,
706        runtime_path: impl Into<PathBuf>,
707        now: Timestamp,
708    ) -> Self {
709        Self::allocate_in(
710            id,
711            policy_id,
712            runtime_path,
713            AttemptWorkspace::Ephemeral,
714            now,
715        )
716    }
717
718    /// The same first step, recording which workspace the directory came from.
719    ///
720    /// `c2` calls this with [`AttemptWorkspace::PersistentSlot`] while holding
721    /// the host allocation lock, so the slot lease is journalled "before package
722    /// or GitHub effects" and a crash between the two leaves a recoverable trace
723    /// rather than an orphaned slot.
724    #[must_use]
725    pub fn allocate_in(
726        id: AttemptId,
727        policy_id: PolicyId,
728        runtime_path: impl Into<PathBuf>,
729        workspace: AttemptWorkspace,
730        now: Timestamp,
731    ) -> Self {
732        Self {
733            id,
734            policy_id,
735            github_runner_id: None,
736            state: AttemptState::Allocated,
737            outcome: None,
738            process_id: None,
739            runtime_path: runtime_path.into(),
740            workspace,
741            created_at: now,
742            terminal_at: None,
743            last_state_change_at: now,
744        }
745    }
746
747    /// Rebuild a journalled attempt.
748    ///
749    /// # Errors
750    /// Any state/outcome/timestamp combination that this crate's own transitions
751    /// cannot produce, so a hand-edited journal cannot inject a `failed` attempt
752    /// that claims to have run a job, or a `finished` one that never reached a
753    /// terminal state.
754    pub fn from_persisted(fields: PersistedAttempt) -> Result<Self, AttemptError> {
755        let PersistedAttempt {
756            id,
757            policy_id,
758            github_runner_id,
759            state,
760            outcome,
761            process_id,
762            runtime_path,
763            workspace_kind,
764            workspace_slot,
765            created_at,
766            terminal_at,
767            last_state_change_at,
768        } = fields;
769
770        // The workspace pair is checked first because it decides which cleanup
771        // algorithm recovery is allowed to run on `runtime_path`. A row that
772        // claims `persistent` with no slot, or `ephemeral` with one, names a
773        // directory whose safe cleanup is undecidable, and
774        // `04-security-recovery.md` requires that to fail closed rather than to
775        // fall back to the destructive branch.
776        let workspace = AttemptWorkspace::from_persisted(workspace_kind, workspace_slot)?;
777
778        match (&outcome, state.is_terminal()) {
779            (None, true) => return Err(AttemptError::TerminalWithoutOutcome { state }),
780            (Some(outcome), false) => {
781                return Err(AttemptError::NonTerminalWithOutcome {
782                    state,
783                    outcome: outcome.clone(),
784                });
785            }
786            (Some(outcome), true) => {
787                let expected = outcome.terminal_state();
788                if state != expected && state != AttemptState::Cleaned {
789                    return Err(AttemptError::OutcomeStateMismatch {
790                        state,
791                        outcome: outcome.clone(),
792                    });
793                }
794            }
795            (None, false) => {}
796        }
797
798        // `terminal_at` is validated on exactly the same footing as `outcome`,
799        // and for the same stated reason. `conclude` is the only writer of both
800        // and sets them together, so `state.is_terminal()` and
801        // `terminal_at.is_some()` are equivalent in anything this crate
802        // produced; a row where they disagree was edited by hand. Without this,
803        // a `finished` attempt with no `terminal_at` loaded cleanly and every
804        // consumer of `terminal_at()` -- retention, reporting, `g2`'s ordering
805        // -- silently saw an attempt that had never concluded.
806        match (terminal_at, state.is_terminal()) {
807            (None, true) => return Err(AttemptError::TerminalWithoutTimestamp { state }),
808            (Some(_), false) => return Err(AttemptError::NonTerminalWithTimestamp { state }),
809            _ => {}
810        }
811
812        // Presence was checked above; *ordering* is checked here, and it is a
813        // separate hazard. `created_at` never moves and every other timestamp is
814        // written by a transition that happens after it, so a row where either
815        // precedes it is one this crate cannot have produced. Without this, a
816        // `finished` row with `created_at: ts(100), terminal_at: Some(ts(0))`
817        // loaded cleanly -- an attempt that concluded a hundred seconds before
818        // it was created -- and every duration computed from the pair came out
819        // negative or wrapped. The hand-edited-journal threat model that
820        // motivates the presence gate covers this equally.
821        if last_state_change_at < created_at {
822            return Err(AttemptError::TimestampsOutOfOrder {
823                state,
824                field: "last_state_change_at",
825                created_at,
826                found: last_state_change_at,
827            });
828        }
829        if let Some(terminal_at) = terminal_at
830            && terminal_at < created_at
831        {
832            return Err(AttemptError::TimestampsOutOfOrder {
833                state,
834                field: "terminal_at",
835                created_at,
836                found: terminal_at,
837            });
838        }
839
840        Ok(Self {
841            id,
842            policy_id,
843            github_runner_id,
844            state,
845            outcome,
846            process_id,
847            runtime_path,
848            workspace,
849            created_at,
850            terminal_at,
851            last_state_change_at,
852        })
853    }
854
855    #[must_use]
856    pub const fn state(&self) -> AttemptState {
857        self.state
858    }
859
860    #[must_use]
861    pub const fn outcome(&self) -> Option<&AttemptOutcome> {
862        self.outcome.as_ref()
863    }
864
865    #[must_use]
866    pub const fn github_runner_id(&self) -> Option<u64> {
867        self.github_runner_id
868    }
869
870    #[must_use]
871    pub const fn process_id(&self) -> Option<u32> {
872        self.process_id
873    }
874
875    #[must_use]
876    pub fn runtime_path(&self) -> &Path {
877        &self.runtime_path
878    }
879
880    /// The immutable allocation fact: disposable, or the persistent slot leased.
881    ///
882    /// There is no setter. Cleanup and recovery dispatch on this
883    /// (`02-target-architecture.md`, "Cleanup and recovery"), so a value that
884    /// could be changed after allocation would let the algorithm chosen for a
885    /// directory disagree with the one it was created under.
886    #[must_use]
887    pub const fn workspace(&self) -> AttemptWorkspace {
888        self.workspace
889    }
890
891    /// Whether this attempt holds a persistent slot lease.
892    ///
893    /// Every uncleaned persistent attempt is a lease, including a terminal one
894    /// whose cleanup failed — which is why this asks about the workspace and not
895    /// about the state.
896    #[must_use]
897    pub const fn holds_slot_lease(&self) -> bool {
898        self.workspace.is_persistent() && !matches!(self.state, AttemptState::Cleaned)
899    }
900
901    #[must_use]
902    pub const fn terminal_at(&self) -> Option<Timestamp> {
903        self.terminal_at
904    }
905
906    /// When the attempt entered its current state. Recovery timeouts run from
907    /// here.
908    #[must_use]
909    pub const fn last_state_change_at(&self) -> Timestamp {
910        self.last_state_change_at
911    }
912
913    #[must_use]
914    pub const fn is_terminal(&self) -> bool {
915        self.state.is_terminal()
916    }
917
918    /// Whether this attempt still holds one of the host's capacity slots.
919    #[must_use]
920    pub const fn counts_against_capacity(&self) -> bool {
921        self.state.counts_against_capacity()
922    }
923
924    fn move_to(&mut self, next: AttemptState, now: Timestamp) -> Result<(), AttemptError> {
925        if !self.state.can_transition_to(next) {
926            return Err(AttemptError::IllegalTransition {
927                from: self.state,
928                to: next,
929            });
930        }
931        self.state = next;
932        self.last_state_change_at = now;
933        Ok(())
934    }
935
936    /// `allocated -> jit_received`.
937    ///
938    /// # Errors
939    /// [`AttemptError::IllegalTransition`] from any other state.
940    pub fn jit_received(&mut self, now: Timestamp) -> Result<(), AttemptError> {
941        self.move_to(AttemptState::JitReceived, now)
942    }
943
944    /// `jit_received -> starting`, recording the child process identity.
945    ///
946    /// # Errors
947    /// [`AttemptError::IllegalTransition`] from any other state.
948    pub fn started(&mut self, process_id: u32, now: Timestamp) -> Result<(), AttemptError> {
949        self.move_to(AttemptState::Starting, now)?;
950        self.process_id = Some(process_id);
951        Ok(())
952    }
953
954    /// `starting -> idle`: the runner registered and is awaiting its one
955    /// assignment.
956    ///
957    /// # Errors
958    /// [`AttemptError::IllegalTransition`] from any other state.
959    pub fn registered_idle(
960        &mut self,
961        github_runner_id: u64,
962        now: Timestamp,
963    ) -> Result<(), AttemptError> {
964        self.move_to(AttemptState::Idle, now)?;
965        self.github_runner_id = Some(github_runner_id);
966        Ok(())
967    }
968
969    /// `starting | idle -> busy`: the runner was assigned its one job.
970    ///
971    /// Both sources are real. A runner may be observed taking a job directly out
972    /// of `starting`, or it may be seen `idle` first and pick the job up on a
973    /// later pass — `e3`'s Scope step 4 walks the second sequence explicitly.
974    ///
975    /// # Errors
976    /// [`AttemptError::IllegalTransition`] from any other state.
977    pub fn assigned_job(
978        &mut self,
979        github_runner_id: u64,
980        now: Timestamp,
981    ) -> Result<(), AttemptError> {
982        self.move_to(AttemptState::Busy, now)?;
983        self.github_runner_id = Some(github_runner_id);
984        Ok(())
985    }
986
987    /// Record the terminal outcome, moving to the state it implies.
988    ///
989    /// # Errors
990    /// [`AttemptError::OutcomeUnreachable`] when the outcome does not belong to
991    /// the current state — `CompletedJob` from anything but `busy`, or
992    /// `ExitedIdleWithoutWork` from anything but `idle` — and
993    /// [`AttemptError::IllegalTransition`] otherwise.
994    pub fn conclude(
995        &mut self,
996        outcome: AttemptOutcome,
997        now: Timestamp,
998    ) -> Result<(), AttemptError> {
999        if !outcome.required_from().contains(&self.state) {
1000            return Err(AttemptError::OutcomeUnreachable {
1001                from: self.state,
1002                outcome,
1003            });
1004        }
1005        self.move_to(outcome.terminal_state(), now)?;
1006        self.terminal_at = Some(now);
1007        self.outcome = Some(outcome);
1008        Ok(())
1009    }
1010
1011    /// `finished | failed | orphaned -> cleaned`.
1012    ///
1013    /// # Errors
1014    /// [`AttemptError::BusyCannotBeCleaned`] for a `busy` attempt — the
1015    /// scale-down case `04-subsystem-contracts.md` forbids — and
1016    /// [`AttemptError::IllegalTransition`] for any other non-terminal state or
1017    /// for an already-cleaned attempt.
1018    pub fn clean(&mut self, now: Timestamp) -> Result<(), AttemptError> {
1019        if self.state == AttemptState::Busy {
1020            return Err(AttemptError::BusyCannotBeCleaned);
1021        }
1022        self.move_to(AttemptState::Cleaned, now)
1023    }
1024}
1025
1026// ---------------------------------------------------------------------------
1027// Ownership
1028// ---------------------------------------------------------------------------
1029
1030/// Ownership rules 1 and 2 (`04-subsystem-contracts.md`).
1031///
1032/// An attempt records its `policy_id`, and the policy records its `host_id`, so
1033/// authorising an attempt is a two-link check and both links matter. `e3`'s
1034/// restart recovery runs this before it touches a process it found on the
1035/// machine: adopting, terminating, or cleaning another host's attempt is the
1036/// failure this rule exists to prevent.
1037///
1038/// # Errors
1039/// [`OwnershipError::PolicyMismatch`] when the attempt does not belong to the
1040/// policy, and [`OwnershipError::ForeignHost`] when the policy does not belong to
1041/// this agent's host.
1042pub fn authorize(
1043    agent_host: HostId,
1044    policy: &ScalePolicy,
1045    attempt: &RunnerAttempt,
1046) -> Result<(), OwnershipError> {
1047    if attempt.policy_id != policy.id {
1048        return Err(OwnershipError::PolicyMismatch {
1049            attempt: attempt.id,
1050            attempt_policy: attempt.policy_id,
1051            policy: policy.id,
1052        });
1053    }
1054    if !policy.is_owned_by(agent_host) {
1055        return Err(OwnershipError::ForeignHost {
1056            policy: policy.id,
1057            owner: policy.host_id,
1058            agent: agent_host,
1059        });
1060    }
1061    Ok(())
1062}
1063
1064// ---------------------------------------------------------------------------
1065// Recovery
1066// ---------------------------------------------------------------------------
1067
1068/// How long an attempt may sit in each pre-terminal state before recovery treats
1069/// it as stuck.
1070///
1071/// **No document in this taskflow states these durations.** `03-control-flows.md`
1072/// flow 2.7 says a surplus runner "exits on its idle timeout" without giving one,
1073/// and flow 4.4 says an expired JIT configuration is discarded without saying
1074/// when it expires. [`RecoveryTimeouts::provisional`] therefore returns values
1075/// chosen here, named so that a caller cannot mistake them for a product
1076/// decision, and there is deliberately no `Default` impl — `e1` and `e3` should
1077/// have to write the numbers down.
1078#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1079pub struct RecoveryTimeouts {
1080    /// How long `allocated` or `jit_received` may last before the JIT
1081    /// configuration is assumed lost.
1082    pub jit_handoff: Elapsed,
1083    /// How long `starting` may last before the runner is assumed not to be
1084    /// coming up.
1085    pub startup: Elapsed,
1086    /// How long `idle` may last, in both directions it is read.
1087    ///
1088    /// For an attempt whose process is already gone it separates the two
1089    /// readings of that exit: past this, the surplus case; before it, a crash.
1090    ///
1091    /// For one whose process is still alive and still registered it is a
1092    /// deadline rather than a reading — the point at which the agent stops the
1093    /// runner itself. Nothing else does: `Runner.Listener run` long-polls for
1094    /// an assignment indefinitely, so this value, and only this value, bounds
1095    /// how long a runner that never gets a job holds its capacity slot and its
1096    /// entry in the target's runner settings.
1097    pub idle: Elapsed,
1098}
1099
1100impl RecoveryTimeouts {
1101    #[must_use]
1102    pub const fn new(jit_handoff: Elapsed, startup: Elapsed, idle: Elapsed) -> Self {
1103        Self {
1104            jit_handoff,
1105            startup,
1106            idle,
1107        }
1108    }
1109
1110    /// Placeholder values, with one exception — see the type documentation.
1111    ///
1112    /// `idle` is no longer a placeholder. It stopped being one when it became
1113    /// the only thing that bounds a live runner: five minutes is long enough
1114    /// that a runner GitHub is about to assign is not stopped out from under
1115    /// the assignment, and short enough that a machine does not carry an
1116    /// unusable slot, and a repository an unusable runner row, for hours. The
1117    /// other two still only separate readings of an event that already
1118    /// happened, and nothing here has had to decide what they should be.
1119    #[must_use]
1120    pub fn provisional() -> Self {
1121        Self {
1122            jit_handoff: Elapsed::seconds(120),
1123            startup: Elapsed::seconds(300),
1124            idle: Elapsed::seconds(300),
1125        }
1126    }
1127}
1128
1129/// What GitHub says about this attempt's runner.
1130///
1131/// Precedence rule 3: "GitHub runner status is authoritative for remote job
1132/// status; local process state is authoritative only for a child process owned by
1133/// this agent." Both halves are inputs here, and neither is allowed to stand in
1134/// for the other.
1135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1136pub enum GithubRunnerObservation {
1137    /// GitHub could not be reached this cycle. Flow 3.3: start nothing, retain
1138    /// what is running, back off. It is emphatically **not** the same as
1139    /// `NotRegistered`, and conflating the two would delete live runners during
1140    /// an outage.
1141    Unreachable,
1142    /// GitHub knows no runner for this attempt.
1143    NotRegistered,
1144    /// GitHub knows the runner.
1145    Registered { busy: bool },
1146}
1147
1148/// One attempt's observed reality at recovery time.
1149#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1150pub struct RecoveryObservation {
1151    /// Whether the child process this agent recorded is still alive. `d1` supplies
1152    /// a process identity that survives a reboot, because a bare PID is reused.
1153    pub process_alive: bool,
1154    pub github: GithubRunnerObservation,
1155}
1156
1157/// What to do about one attempt found in the journal at startup.
1158#[derive(Debug, Clone, PartialEq, Eq)]
1159pub enum RecoveryDecision {
1160    /// Already cleaned; there is nothing left to do.
1161    Nothing,
1162    /// Terminal but not yet cleaned: remove the runtime directory and mark it
1163    /// `cleaned`.
1164    Clean,
1165    /// The process is alive and this attempt is still ours. `e3`: "an attempt
1166    /// whose process still runs is adopted, not duplicated."
1167    Adopt,
1168    /// Nothing is decidable yet; look again next cycle.
1169    Wait,
1170    /// GitHub is unreachable. Decide nothing destructive during an outage
1171    /// (flow 3.3).
1172    Defer,
1173    /// Move the attempt to this state to match what was observed.
1174    ///
1175    /// **Where the observed state is live, the caller must adopt supervision of
1176    /// the process independently of this decision.** `RecoveryDecision` has no
1177    /// way to express "adopt *and* observe" — the two are separate variants —
1178    /// so `Starting` with `process_alive` and `Registered { busy: false }`
1179    /// returns `Observe(Idle)` and never [`Self::Adopt`], because GitHub is
1180    /// authoritative for the remote status while the local process is
1181    /// authoritative for supervision, and both facts are true at once.
1182    /// `Idle` with `process_alive` and `Registered { busy: true }` returns
1183    /// `Observe(Busy)` for exactly the same reason; it did not always, and the
1184    /// arm's own comment records what that cost.
1185    ///
1186    /// **An `Observe` decision must be applied *and persisted*, or it repeats
1187    /// forever.** The decision is a pure function of the journalled state and
1188    /// the observation, so a caller that moves the in-memory attempt without
1189    /// writing the new state back will read the same stale state on the next
1190    /// pass and be handed the same decision, indefinitely.
1191    Observe(AttemptState),
1192    /// Conclude the attempt with this outcome.
1193    ///
1194    /// **Terminal, and therefore a capacity release.** An attempt is only
1195    /// concluded where the thing it was supervising is already gone; see
1196    /// [`Self::Terminate`] for the case where it is not.
1197    Conclude(AttemptOutcome),
1198    /// The process is still alive but the attempt cannot go on: **stop the
1199    /// process, and only once it is gone record this outcome.**
1200    ///
1201    /// **Two producers, and the payload is what separates them.** `starting`
1202    /// past its registration timeout carries
1203    /// [`FailureReason::RegistrationTimedOut`]; `idle` past its idle timeout,
1204    /// with GitHub still reporting the runner registered and unassigned,
1205    /// carries [`AttemptOutcome::ExitedIdleWithoutWork`] — flow 2.7's surplus
1206    /// runner, which is a normal outcome and not a failure at all. The two
1207    /// share this variant because they need the identical *sequence* — signal,
1208    /// confirm the process is gone, then record — and differ only in what is
1209    /// recorded at the end. A caller that hardcodes either reason will
1210    /// mislabel the other, so the payload is the caller's instruction, not
1211    /// decoration.
1212    ///
1213    /// **Why this is not a [`Self::Conclude`].** `Conclude` moves the attempt to
1214    /// a terminal state, and a terminal attempt no longer
1215    /// [counts against capacity](AttemptState::counts_against_capacity) — so
1216    /// concluding one whose process is still running hands the host back a slot
1217    /// it is still using. The agent then starts a replacement runner beside a
1218    /// live, unregistered one that may yet register and take a job, for an
1219    /// attempt the journal already calls `failed`. There is no
1220    /// `RecoveryDecision` that would have expressed the fix: [`Self::Adopt`]
1221    /// means take over supervision and [`Self::Clean`] means delete a runtime
1222    /// directory, and neither stops anything.
1223    ///
1224    /// **What happens to the capacity slot.** Nothing, until the process is
1225    /// actually gone. The attempt stays in its current, non-terminal state and
1226    /// keeps holding its slot for as long as the runner it started is running,
1227    /// which is the honest answer — the resources are genuinely occupied. The
1228    /// slot comes back at the moment the caller applies the payload through
1229    /// [`RunnerAttempt::conclude`], which it does only after the process has
1230    /// exited.
1231    ///
1232    /// **It is safe to re-derive in one direction, and owes a debt in the
1233    /// other.** The decision is a pure function of the journalled state and the
1234    /// observation, so an agent that dies *before* terminating sees a live
1235    /// process and a larger `elapsed` on the next pass and is handed this same
1236    /// decision again. That half costs nothing and needs nothing.
1237    ///
1238    /// **The other half is a known defect, and it is not harmless.** An agent
1239    /// that terminates the process and then dies *before* writing the outcome
1240    /// observes `process_alive: false` next time and reaches the ordinary
1241    /// [`Self::Conclude`] arm, which records
1242    /// [`FailureReason::ProcessExitedUnexpectedly`]. The process did not exit
1243    /// unexpectedly; this agent killed it. That is precisely the diagnosis
1244    /// [`FailureReason::RegistrationTimedOut`] was split out to prevent — the
1245    /// two reasons send an operator to different places, and this window sends
1246    /// them to a crash investigation (logs, exit code, a corrupt package) for
1247    /// what was a registration failure.
1248    ///
1249    /// **It cannot be fixed here, and the alternative was weighed rather than
1250    /// waved off.** This function's inputs are the journalled state and a
1251    /// [`RecoveryObservation`], and neither carries the fact that separates the
1252    /// two cases: a process this agent killed and a process that crashed on its
1253    /// own present the *same observation*. Using `RegistrationTimedOut` in the
1254    /// dead-process arm once `elapsed >= startup` would close this window at the
1255    /// price of a wider one — every genuine early crash first observed after a
1256    /// restart longer than the startup window would then be reported as a runner
1257    /// that "is running but did not register", to an operator who can see that
1258    /// it is not running. That trades a rare wrong reason for a common false
1259    /// claim about liveness, in the one direction
1260    /// [`FailureReason::ProcessExitedUnexpectedly`]'s own documentation says
1261    /// spends the credibility of every other message this product prints.
1262    /// `tests::no_decision_calls_a_dead_process_live` is what stops that trade
1263    /// being made by accident later.
1264    ///
1265    /// **So the obligation is `e3`'s, and it is a persistence one.** The
1266    /// distinguishing fact exists only at the moment terminate-intent is formed,
1267    /// and the only way to carry it across a crash is to write it down. `e3`
1268    /// journals the intent *before* it signals the process, and on a later pass
1269    /// concludes an attempt it finds so marked with
1270    /// [`FailureReason::TerminatedAfterRegistrationTimeout`] rather than with
1271    /// whatever this function derived from an observation that could not know.
1272    /// Until `e3` does that, the window stands.
1273    ///
1274    /// **Why that closure needs a reason of its own, and not
1275    /// `RegistrationTimedOut`.** On the pass where `e3` reads the mark back, the
1276    /// process is dead — `e3` killed it, which is the whole reason the mark is
1277    /// there. `RegistrationTimedOut` renders as "the runner process is running
1278    /// but did not register", so concluding with it would print exactly the
1279    /// false liveness claim the paragraph above rejects option A for: the same
1280    /// sentence, about a process an operator can see is gone, moved one pass
1281    /// later. Rewording that string instead is not open either —
1282    /// `tests::the_two_starting_failures_read_differently_to_an_operator` pins
1283    /// it on "running", which is correct for the live case it names.
1284    ///
1285    /// [`FailureReason::TerminatedAfterRegistrationTimeout`] is that reason.
1286    /// It is true of a dead process, it says who stopped it and why, and it
1287    /// sends an operator to the networking and configuration fix rather than to
1288    /// a crash investigation — which is the whole distinction
1289    /// [`FailureReason::RegistrationTimedOut`] was split out to draw. Nothing in
1290    /// this function derives it, and nothing should: it is a claim about an
1291    /// action this agent took, not about anything a [`RecoveryObservation`]
1292    /// reports. `tests::no_decision_calls_a_dead_process_live` covers it
1293    /// alongside the other two liveness-claiming reasons, so the day something
1294    /// here does start deriving it — which it legitimately might, once the mark
1295    /// is journalled where this function can read it — it may only do so beside
1296    /// a process the observation says is gone.
1297    ///
1298    /// **The one thing this does not bound** is a process that refuses to die.
1299    /// The slot is held until it does. That is a worse outcome than concluding
1300    /// early only if the runner was never going to register, and a better one in
1301    /// every case where it was — and unlike the early conclusion it cannot
1302    /// oversubscribe the host.
1303    Terminate(AttemptOutcome),
1304}
1305
1306/// Decide what to do about one journalled attempt after a restart, or on any
1307/// reconciliation pass.
1308///
1309/// Time enters only through `clock`, and only as `now - attempt.
1310/// last_state_change_at()`, so the whole function is exercised by advancing a
1311/// [`crate::model::Clock`] the test controls.
1312#[must_use]
1313pub fn recovery_decision(
1314    attempt: &RunnerAttempt,
1315    observation: RecoveryObservation,
1316    timeouts: RecoveryTimeouts,
1317    clock: &dyn Clock,
1318) -> RecoveryDecision {
1319    use AttemptState as S;
1320    use GithubRunnerObservation as G;
1321
1322    let state = attempt.state();
1323
1324    if state == S::Cleaned {
1325        return RecoveryDecision::Nothing;
1326    }
1327    if state.is_terminal() {
1328        return RecoveryDecision::Clean;
1329    }
1330    if observation.github == G::Unreachable {
1331        return RecoveryDecision::Defer;
1332    }
1333
1334    let elapsed = clock.now() - attempt.last_state_change_at();
1335
1336    match state {
1337        S::Allocated | S::JitReceived => {
1338            if observation.process_alive {
1339                // Precedence rule 3's other half: local process state is
1340                // authoritative for a child process this agent owns. `e3`: an
1341                // attempt whose process still runs is adopted, not duplicated.
1342                RecoveryDecision::Adopt
1343            } else {
1344                match observation.github {
1345                    // The crash window `e3` exists for. A runner cannot register
1346                    // without having received its JIT configuration and started,
1347                    // so a registration is proof the attempt got further than
1348                    // the journal records: the `starting` write was lost, not
1349                    // the attempt. This branch used to consult only
1350                    // `process_alive` and the clock, which abandoned a live
1351                    // runner as `failed` and left its registration at GitHub
1352                    // unreconciled and unremoved.
1353                    //
1354                    // Each state takes the one *forward* edge out of itself, so
1355                    // recovery walks the diagram rather than jumping across it:
1356                    // `allocated -> jit_received` (the registration proves the
1357                    // JIT configuration arrived) and `jit_received -> starting`.
1358                    // `busy` is deliberately not consulted here -- neither state
1359                    // has an edge to it, and the next pass, from `starting`, is
1360                    // where that distinction becomes legal and is drawn.
1361                    //
1362                    // This is a genuine recovery path and not a workaround for a
1363                    // missing edge: GitHub really is reporting a live runner,
1364                    // and each step really did happen. It survived the diagram
1365                    // amendment unchanged.
1366                    G::Registered { .. } => RecoveryDecision::Observe(if state == S::Allocated {
1367                        S::JitReceived
1368                    } else {
1369                        S::Starting
1370                    }),
1371                    // Past the handoff deadline with nothing at GitHub and no
1372                    // process: the attempt died before registering, and since
1373                    // the amendment it can say so directly. It previously had to
1374                    // report `NoLegalTransition` and hold its host capacity slot
1375                    // for ever.
1376                    //
1377                    // The two states get different reasons because they know
1378                    // different things. At `allocated` no configuration was ever
1379                    // recorded as arriving, so the request itself did not
1380                    // complete; at `jit_received` one arrived and was never
1381                    // claimed, which is flow 4.4's expiry.
1382                    G::NotRegistered => {
1383                        if elapsed >= timeouts.jit_handoff {
1384                            RecoveryDecision::Conclude(AttemptOutcome::failed(
1385                                if state == S::Allocated {
1386                                    FailureReason::JitRequestFailed
1387                                } else {
1388                                    FailureReason::JitExpired
1389                                },
1390                            ))
1391                        } else {
1392                            RecoveryDecision::Wait
1393                        }
1394                    }
1395                    G::Unreachable => unreachable!("handled above"),
1396                }
1397            }
1398        }
1399
1400        S::Starting => match observation.github {
1401            // GitHub is authoritative for remote job status, and both of these
1402            // are legal edges out of `starting`.
1403            G::Registered { busy: true } => RecoveryDecision::Observe(S::Busy),
1404            G::Registered { busy: false } => RecoveryDecision::Observe(S::Idle),
1405            // Three cases, not two, and the third is the one that matters.
1406            //
1407            // A live process inside its startup window is adopted, the same as
1408            // in every other pre-terminal state: `e3` must take over supervision
1409            // rather than start a second runner for the same work.
1410            //
1411            // A process that is *gone* is flow 2's "runner exit before job
1412            // acceptance". Since the amendment that is recordable, so the
1413            // attempt concludes and gives its slot back.
1414            //
1415            // A process that is alive and past its deadline is neither, and
1416            // collapsing it into the second was a real defect: `Conclude` makes
1417            // the attempt terminal, a terminal attempt stops counting against
1418            // capacity, and the host therefore got its slot back while the
1419            // runner was still running -- free to register late and take a job
1420            // for an attempt the journal already called `failed`, beside the
1421            // replacement the freed slot let the agent start. It also read
1422            // `ProcessExitedUnexpectedly` to an operator looking at the process
1423            // in a task manager. `Terminate` says the true thing and keeps the
1424            // slot until the process is actually gone.
1425            //
1426            // The dead-process arm below carries a residual this arm cannot
1427            // close: an agent that terminated the process and died before
1428            // writing the outcome lands there and records
1429            // `ProcessExitedUnexpectedly` for a process it killed itself. The
1430            // fact that would separate the two is not in this function's
1431            // inputs -- a killed process and a crashed one are the same
1432            // observation -- so the fix is `e3` journalling terminate-intent
1433            // before signalling and concluding the marked attempt with
1434            // `FailureReason::TerminatedAfterRegistrationTimeout`, not a
1435            // rearrangement here. That reason exists precisely because the
1436            // process is gone by then, so the closure cannot reuse
1437            // `RegistrationTimedOut` without printing the same false liveness
1438            // claim one pass later. See `RecoveryDecision::Terminate`, which
1439            // names the trade and the owner, and
1440            // `tests::no_decision_calls_a_dead_process_live`, which reds if
1441            // somebody closes it here instead.
1442            G::NotRegistered => {
1443                if !observation.process_alive {
1444                    RecoveryDecision::Conclude(AttemptOutcome::failed(
1445                        FailureReason::ProcessExitedUnexpectedly,
1446                    ))
1447                } else if elapsed < timeouts.startup {
1448                    RecoveryDecision::Adopt
1449                } else {
1450                    RecoveryDecision::Terminate(AttemptOutcome::failed(
1451                        FailureReason::RegistrationTimedOut,
1452                    ))
1453                }
1454            }
1455            G::Unreachable => unreachable!("handled above"),
1456        },
1457
1458        // GitHub is consulted **first**, exactly as at `starting` above, and the
1459        // symmetry is load-bearing rather than tidy. This arm used to
1460        // short-circuit on `process_alive` before looking at GitHub, so the same
1461        // conflict -- a live process that GitHub reports as `busy` -- resolved
1462        // one way here and the opposite way one state earlier. What that cost
1463        // was not an inconsistency but a wrong outcome: `Adopt` left the journal
1464        // saying `idle`, so `last_state_change_at` went on pointing at the idle
1465        // entry, and a runner that later crashed *during its job* had its idle
1466        // timeout elapse and was concluded `ExitedIdleWithoutWork` -- the benign
1467        // surplus exit, recorded for a mid-job crash, inverting the one
1468        // distinction this module exists to keep. Nothing downstream could
1469        // catch it either: `required_from` for that outcome is `&[Idle]`, and
1470        // the attempt really was `idle`.
1471        //
1472        // `Observe(Busy)` is returned whether or not the process is alive: the
1473        // caller adopts supervision independently of this decision, which is
1474        // what [`RecoveryDecision::Observe`] already instructs and what
1475        // `starting` has always relied on.
1476        S::Idle => match observation.github {
1477            // GitHub says this runner took a job, and GitHub is authoritative
1478            // for remote job status (precedence rule 3). `idle -> busy` was
1479            // added by the amendment *for this observation*; before this change
1480            // the only arm that could reach it was the one where the process is
1481            // already dead.
1482            G::Registered { busy: true } => RecoveryDecision::Observe(S::Busy),
1483            // GitHub agrees with the journal, so there is no state to move to.
1484            // A dead process means supervision is lost while the remote
1485            // registration outlived it and needs removing.
1486            //
1487            // A *live* one is adopted only while it is still inside the idle
1488            // timeout. Past it, this is flow 2.7's surplus runner and the agent
1489            // has to end it, because nothing else will: the runner is spawned as
1490            // a bare `Runner.Listener run`, and that process has no idle timeout
1491            // of its own -- it long-polls for an assignment until something
1492            // stops it. This arm used to answer `Adopt` for every elapsed time,
1493            // which is why it never was stopped. A registered, unassigned runner
1494            // then holds its capacity slot and its entry in the target's runner
1495            // settings for as long as the host stays up; one observed in the
1496            // field sat here for 27 hours across two restarts of nothing.
1497            //
1498            // `Terminate`, not `Conclude`: the process is alive, so the slot may
1499            // not be returned until it is gone. The caller signals it, re-reads
1500            // liveness, and only then applies the payload -- the same sequence
1501            // `starting` above relies on, and the reason that decision carries
1502            // its outcome rather than the caller deriving one.
1503            G::Registered { busy: false } => {
1504                if !observation.process_alive {
1505                    RecoveryDecision::Conclude(AttemptOutcome::Orphaned)
1506                } else if elapsed >= timeouts.idle {
1507                    RecoveryDecision::Terminate(AttemptOutcome::ExitedIdleWithoutWork)
1508                } else {
1509                    RecoveryDecision::Adopt
1510                }
1511            }
1512            // Nothing at GitHub. A live process is still ours to supervise.
1513            // Otherwise the surplus case and the crash case, separated by the
1514            // clock: a runner that sat out its whole idle timeout and then
1515            // exited with no registration left behind did what flow 2.7
1516            // describes. One that vanished early did not.
1517            G::NotRegistered => {
1518                if observation.process_alive {
1519                    RecoveryDecision::Adopt
1520                } else if elapsed >= timeouts.idle {
1521                    RecoveryDecision::Conclude(AttemptOutcome::ExitedIdleWithoutWork)
1522                } else {
1523                    RecoveryDecision::Conclude(AttemptOutcome::failed(
1524                        FailureReason::ProcessExitedUnexpectedly,
1525                    ))
1526                }
1527            }
1528            G::Unreachable => unreachable!("handled above"),
1529        },
1530
1531        S::Busy => {
1532            if observation.process_alive {
1533                RecoveryDecision::Adopt
1534            } else {
1535                // `e3`: "an attempt whose process is gone and whose runner is
1536                // unknown to GitHub is `orphaned` and cleaned". The agent never
1537                // reports a job as complete, so a lost supervision is recorded as
1538                // exactly that and not guessed into a success.
1539                RecoveryDecision::Conclude(AttemptOutcome::Orphaned)
1540            }
1541        }
1542
1543        S::Finished | S::Failed | S::Orphaned | S::Cleaned => {
1544            unreachable!("terminal states are handled above")
1545        }
1546    }
1547}
1548
1549/// How many of these attempts still occupy a host capacity slot.
1550///
1551/// Saturating rather than wrapping: a host cannot hold more than `u16::MAX`
1552/// attempts, and if some caller ever produced that many, reporting the ceiling is
1553/// safe where wrapping to zero would let the allocator start `u16::MAX` more.
1554#[must_use]
1555pub fn active_count<'a>(attempts: impl IntoIterator<Item = &'a RunnerAttempt>) -> u16 {
1556    attempts
1557        .into_iter()
1558        .filter(|a| a.counts_against_capacity())
1559        .fold(0u16, |acc, _| acc.saturating_add(1))
1560}
1561
1562/// How many of these attempts still occupy a slot **and belong to one policy**.
1563///
1564/// The per-policy counterpart of [`active_count`], and the term
1565/// [`crate::capacity::HostAllocator::allocate`] subtracts. The two are not
1566/// interchangeable: [`active_count`] is the host-wide total that bounds D9's
1567/// ceiling, this one is the per-policy figure that bounds D7's, and substituting
1568/// either for the other is silent — the host-wide count in a per-policy slot
1569/// starves every policy but the first, and a zero in place of this one starts a
1570/// duplicate runner on every poll.
1571#[must_use]
1572pub fn active_count_for<'a>(
1573    policy_id: PolicyId,
1574    attempts: impl IntoIterator<Item = &'a RunnerAttempt>,
1575) -> u16 {
1576    attempts
1577        .into_iter()
1578        .filter(|a| a.policy_id == policy_id && a.counts_against_capacity())
1579        .fold(0u16, |acc, _| acc.saturating_add(1))
1580}
1581
1582#[cfg(test)]
1583mod tests {
1584    use super::*;
1585    use crate::model::{Arch, CachePolicy, HostLabel, Os, ScaleTarget};
1586    use crate::policy::{PolicyMode, RoutingLabels};
1587    use std::num::NonZeroU16;
1588
1589    #[derive(Debug)]
1590    struct StubClock(std::sync::Mutex<Timestamp>);
1591
1592    impl StubClock {
1593        fn at(secs: i64) -> Self {
1594            Self(std::sync::Mutex::new(ts(secs)))
1595        }
1596        fn set(&self, secs: i64) {
1597            *self.0.lock().unwrap() = ts(secs);
1598        }
1599    }
1600
1601    impl Clock for StubClock {
1602        fn now(&self) -> Timestamp {
1603            *self.0.lock().unwrap()
1604        }
1605    }
1606
1607    fn ts(secs: i64) -> Timestamp {
1608        chrono::DateTime::from_timestamp(secs, 0).expect("valid timestamp")
1609    }
1610
1611    fn attempt_in(state: AttemptState, entered_at: i64) -> RunnerAttempt {
1612        let mut attempt = RunnerAttempt::allocate(
1613            AttemptId::from_u128(1),
1614            PolicyId::from_u128(1),
1615            "runtime/p/a",
1616            ts(0),
1617        );
1618        // Set the state directly: only possible from inside the module, which is
1619        // exactly why the state-machine tests live here.
1620        attempt.state = state;
1621        attempt.last_state_change_at = ts(entered_at);
1622        attempt
1623    }
1624
1625    /// The same, under a nominated policy, for the per-policy count.
1626    fn attempt_for(policy_id: PolicyId, state: AttemptState, entered_at: i64) -> RunnerAttempt {
1627        let mut attempt = attempt_in(state, entered_at);
1628        attempt.policy_id = policy_id;
1629        attempt
1630    }
1631
1632    fn a_policy(host: HostId, policy_id: PolicyId) -> ScalePolicy {
1633        ScalePolicy::new(
1634            policy_id,
1635            ScaleTarget::repository("o/r").unwrap(),
1636            1,
1637            host,
1638            PolicyMode::autoscale(
1639                RoutingLabels::derive(&HostLabel::new("home").unwrap(), Os::Windows, Arch::X64),
1640                0,
1641                NonZeroU16::new(1).unwrap(),
1642            )
1643            .unwrap(),
1644            CachePolicy::default(),
1645        )
1646    }
1647
1648    // =======================================================================
1649    // The state machine, both directions
1650    // =======================================================================
1651
1652    /// The diagram from `04-subsystem-contracts.md`, transcribed by hand.
1653    ///
1654    /// **Deliberately a second copy of [`AttemptState::LEGAL`]**, for the reason
1655    /// given on `policy::tests::diagram_edges`: a test whose expectation is read
1656    /// out of the constant under test asserts only that the constant equals
1657    /// itself, and would accept any edge someone added to it.
1658    ///
1659    /// ```text
1660    /// allocated -> jit_received -> starting -> idle | busy
1661    /// idle -> busy
1662    /// allocated | jit_received | starting -> failed | orphaned
1663    /// idle | busy -> finished | failed | orphaned
1664    /// finished | failed | orphaned -> cleaned
1665    /// ```
1666    fn diagram_edges() -> Vec<(AttemptState, AttemptState)> {
1667        use AttemptState::*;
1668        // Line 1.
1669        let mut edges = vec![
1670            (Allocated, JitReceived),
1671            (JitReceived, Starting),
1672            (Starting, Idle),
1673            (Starting, Busy),
1674        ];
1675        // Line 2, added by the 2026-08-21 amendment.
1676        edges.push((Idle, Busy));
1677        // Line 3, added by the same amendment.
1678        for from in [Allocated, JitReceived, Starting] {
1679            for to in [Failed, Orphaned] {
1680                edges.push((from, to));
1681            }
1682        }
1683        // Line 4.
1684        for from in [Idle, Busy] {
1685            for to in [Finished, Failed, Orphaned] {
1686                edges.push((from, to));
1687            }
1688        }
1689        // Line 5.
1690        for from in [Finished, Failed, Orphaned] {
1691            edges.push((from, Cleaned));
1692        }
1693        edges
1694    }
1695
1696    #[test]
1697    fn every_attempt_state_transition_is_legal_exactly_where_the_diagram_says() {
1698        let expected = diagram_edges();
1699        assert_eq!(
1700            expected.len(),
1701            20,
1702            "the transcription itself changed; check it against the diagram"
1703        );
1704
1705        let mut legal_seen = 0usize;
1706        let mut illegal_seen = 0usize;
1707
1708        for from in AttemptState::ALL {
1709            for to in AttemptState::ALL {
1710                let expected_legal = expected.contains(&(from, to));
1711                let mut attempt = attempt_in(from, 0);
1712                let result = attempt.move_to(to, ts(10));
1713
1714                if expected_legal {
1715                    legal_seen += 1;
1716                    assert!(
1717                        result.is_ok(),
1718                        "{from} -> {to} is in the diagram and must be accepted"
1719                    );
1720                    assert_eq!(attempt.state(), to);
1721                    assert_eq!(attempt.last_state_change_at(), ts(10));
1722                } else {
1723                    illegal_seen += 1;
1724                    assert!(
1725                        matches!(result, Err(AttemptError::IllegalTransition { .. })),
1726                        "{from} -> {to} is not in the diagram and must be rejected"
1727                    );
1728                    assert_eq!(
1729                        attempt.state(),
1730                        from,
1731                        "a refused transition changes nothing"
1732                    );
1733                    assert_eq!(attempt.last_state_change_at(), ts(0));
1734                }
1735            }
1736        }
1737
1738        assert_eq!(legal_seen, 20);
1739        assert_eq!(illegal_seen, 81 - 20);
1740
1741        // And the published constant matches the transcription.
1742        let mut published = AttemptState::LEGAL.to_vec();
1743        let mut transcribed = expected;
1744        published.sort_unstable();
1745        transcribed.sort_unstable();
1746        assert_eq!(published, transcribed);
1747    }
1748
1749    #[test]
1750    fn every_live_state_has_a_terminal_edge_so_no_attempt_can_hold_a_slot_forever() {
1751        // The operational half of the amendment, asserted as the property it is
1752        // rather than as a list of edges. An attempt occupies a host capacity
1753        // slot for exactly as long as it is non-terminal, so a live state with
1754        // no terminal edge is a state an attempt can be stranded in -- which is
1755        // how two failed JIT requests wedged a `host_capacity: 2` host into
1756        // starting zero runners, with no error and no cleanup path.
1757        for state in AttemptState::ALL {
1758            if state.is_terminal() {
1759                continue;
1760            }
1761            // Read through `active_count`, which is the function the
1762            // reconciliation formula actually calls, rather than through
1763            // `state.counts_against_capacity()`. The latter is defined as
1764            // `!self.is_terminal()`, so asserting it one line under
1765            // `if state.is_terminal() { continue; }` is `assert!(true)` and
1766            // catches nothing; this asserts the same property against a second
1767            // reader that can regress on its own -- an inverted or mistyped
1768            // filter predicate in `active_count` fails here and nowhere else in
1769            // this test.
1770            assert_eq!(
1771                active_count([&attempt_in(state, 0)]),
1772                1,
1773                "{state} is non-terminal, so the reconciliation formula must \
1774                 still be subtracting it"
1775            );
1776            assert!(
1777                state.can_transition_to(AttemptState::Failed)
1778                    || state.can_transition_to(AttemptState::Finished),
1779                "{state} has no way to conclude, so an attempt in it holds a host \
1780                 capacity slot permanently"
1781            );
1782            assert!(
1783                state.can_transition_to(AttemptState::Orphaned),
1784                "{state} has no orphan edge, so an attempt found in it after a \
1785                 restart cannot be recorded"
1786            );
1787            assert!(
1788                AttemptState::CONCLUDABLE_FROM.contains(&state),
1789                "{state} is live but missing from CONCLUDABLE_FROM"
1790            );
1791        }
1792        assert_eq!(
1793            AttemptState::CONCLUDABLE_FROM.len(),
1794            AttemptState::ALL
1795                .iter()
1796                .filter(|s| !s.is_terminal())
1797                .count(),
1798            "CONCLUDABLE_FROM must list every non-terminal state and nothing else"
1799        );
1800    }
1801
1802    #[test]
1803    fn an_attempt_state_cannot_transition_to_itself() {
1804        for state in AttemptState::ALL {
1805            assert!(
1806                !state.can_transition_to(state),
1807                "{state} -> {state} is not an edge in the diagram"
1808            );
1809        }
1810    }
1811
1812    #[test]
1813    fn cleaned_is_absorbing() {
1814        for to in AttemptState::ALL {
1815            assert!(
1816                !AttemptState::Cleaned.can_transition_to(to),
1817                "cleaned -> {to} must not exist; a cleaned runtime directory is gone"
1818            );
1819        }
1820    }
1821
1822    #[test]
1823    fn the_documented_happy_path_walks_allocated_to_cleaned() {
1824        // `e3`: "A full attempt runs allocated -> jit_received -> starting ->
1825        // busy -> finished -> cleaned".
1826        let mut attempt = RunnerAttempt::allocate(
1827            AttemptId::from_u128(1),
1828            PolicyId::from_u128(1),
1829            "runtime/p/a",
1830            ts(0),
1831        );
1832        assert_eq!(attempt.state(), AttemptState::Allocated);
1833        assert!(attempt.counts_against_capacity());
1834
1835        attempt.jit_received(ts(1)).unwrap();
1836        attempt.started(4242, ts(2)).unwrap();
1837        assert_eq!(attempt.process_id(), Some(4242));
1838
1839        attempt.assigned_job(73, ts(3)).unwrap();
1840        assert_eq!(attempt.state(), AttemptState::Busy);
1841        assert_eq!(attempt.github_runner_id(), Some(73));
1842
1843        attempt
1844            .conclude(AttemptOutcome::CompletedJob, ts(9))
1845            .unwrap();
1846        assert_eq!(attempt.state(), AttemptState::Finished);
1847        assert_eq!(attempt.terminal_at(), Some(ts(9)));
1848        assert!(!attempt.counts_against_capacity());
1849
1850        attempt.clean(ts(10)).unwrap();
1851        assert_eq!(attempt.state(), AttemptState::Cleaned);
1852    }
1853
1854    // =======================================================================
1855    // Outcome: the surplus attempt
1856    // =======================================================================
1857
1858    #[test]
1859    fn an_attempt_that_exits_idle_without_work_is_terminal_and_not_a_failure() {
1860        // `b1`: "An attempt that exits idle without work reaches a terminal state
1861        // carrying an outcome distinguishable from a failure."
1862        let mut surplus = RunnerAttempt::allocate(
1863            AttemptId::from_u128(1),
1864            PolicyId::from_u128(1),
1865            "runtime/p/a",
1866            ts(0),
1867        );
1868        surplus.jit_received(ts(1)).unwrap();
1869        surplus.started(1, ts(2)).unwrap();
1870        surplus.registered_idle(73, ts(3)).unwrap();
1871        surplus
1872            .conclude(AttemptOutcome::ExitedIdleWithoutWork, ts(300))
1873            .unwrap();
1874
1875        assert!(surplus.is_terminal());
1876        assert_eq!(surplus.state(), AttemptState::Finished);
1877
1878        let outcome = surplus.outcome().expect("a terminal attempt carries one");
1879        assert!(outcome.is_idle_exit());
1880        assert!(
1881            !outcome.is_failure(),
1882            "the surplus runner is an accepted, bounded cost of having no job \
1883             reservation -- presenting it as a fault sends an operator hunting \
1884             something that did not happen"
1885        );
1886        assert!(!outcome.ran_a_job());
1887
1888        // And it is cleaned like any other terminal attempt.
1889        surplus.clean(ts(301)).unwrap();
1890        assert_eq!(surplus.state(), AttemptState::Cleaned);
1891    }
1892
1893    #[test]
1894    fn an_idle_exit_is_distinguishable_from_a_failure_and_from_a_completed_job() {
1895        // The three outcomes `g2` must render apart.
1896        let idle = AttemptOutcome::ExitedIdleWithoutWork;
1897        let failed = AttemptOutcome::failed(FailureReason::ProcessStartFailed);
1898        let done = AttemptOutcome::CompletedJob;
1899
1900        assert_ne!(idle, failed);
1901        assert_ne!(idle, done);
1902        assert_ne!(failed, done);
1903
1904        assert_eq!(idle.terminal_state(), AttemptState::Finished);
1905        assert_eq!(done.terminal_state(), AttemptState::Finished);
1906        assert_eq!(failed.terminal_state(), AttemptState::Failed);
1907        assert_eq!(
1908            AttemptOutcome::Orphaned.terminal_state(),
1909            AttemptState::Orphaned
1910        );
1911
1912        // `finished` alone does not say which happened; the outcome does. This is
1913        // the assertion that would fail if someone dropped the outcome field and
1914        // let `g2` infer from the state.
1915        assert_eq!(idle.terminal_state(), done.terminal_state());
1916        assert_ne!(idle, done);
1917
1918        assert!(!idle.is_failure());
1919        assert!(failed.is_failure());
1920        assert!(AttemptOutcome::Orphaned.is_failure());
1921    }
1922
1923    #[test]
1924    fn an_outcome_cannot_be_recorded_from_a_state_that_could_not_produce_it() {
1925        // A runner that never got a job cannot have run one.
1926        let mut idle = attempt_in(AttemptState::Idle, 0);
1927        assert!(matches!(
1928            idle.conclude(AttemptOutcome::CompletedJob, ts(1)),
1929            Err(AttemptError::OutcomeUnreachable {
1930                from: AttemptState::Idle,
1931                ..
1932            })
1933        ));
1934        assert_eq!(idle.state(), AttemptState::Idle);
1935        assert!(idle.outcome().is_none());
1936
1937        // And a runner that was executing a job did not exit idle without work.
1938        let mut busy = attempt_in(AttemptState::Busy, 0);
1939        assert!(matches!(
1940            busy.conclude(AttemptOutcome::ExitedIdleWithoutWork, ts(1)),
1941            Err(AttemptError::OutcomeUnreachable {
1942                from: AttemptState::Busy,
1943                ..
1944            })
1945        ));
1946
1947        // Failure and orphaning are reachable from every live state, including
1948        // the three pre-registration ones the amendment gave terminal edges.
1949        for state in AttemptState::CONCLUDABLE_FROM {
1950            attempt_in(*state, 0)
1951                .conclude(AttemptOutcome::failed(FailureReason::JitExpired), ts(1))
1952                .unwrap_or_else(|e| panic!("{state} must be able to fail: {e}"));
1953            attempt_in(*state, 0)
1954                .conclude(AttemptOutcome::Orphaned, ts(1))
1955                .unwrap_or_else(|e| panic!("{state} must be able to orphan: {e}"));
1956        }
1957
1958        // And from a terminal state nothing can be concluded at all -- a second
1959        // conclusion would overwrite the first.
1960        for state in AttemptState::ALL.iter().filter(|s| s.is_terminal()) {
1961            assert!(
1962                attempt_in(*state, 0)
1963                    .conclude(AttemptOutcome::Orphaned, ts(1))
1964                    .is_err(),
1965                "{state} has already concluded"
1966            );
1967        }
1968    }
1969
1970    /// The earliest state at which the agent learns each fact, from flow 2's own
1971    /// step order.
1972    ///
1973    /// **This match is exhaustive and carries no wildcard, and that is the whole
1974    /// mechanism.** The assertion it replaced read
1975    /// `assert_eq!(cases.len(), 7, "FailureReason has seven variants; ...")`,
1976    /// and `cases.len()` on a `[_; 7]` is the compile-time constant `7`: the
1977    /// assertion was `7 == 7` and could not fail. Measured on the code as it
1978    /// stood, adding an eighth variant produced exactly one error — `E0004` from
1979    /// `Display`'s match — and once that arm was written the suite was green
1980    /// with the new variant unreachable and untested. Here, a tenth variant
1981    /// stops this file compiling until somebody says which state produces it,
1982    /// and `all_failure_reasons_are_reachable_from_the_state_that_produces_them`
1983    /// then proves the answer.
1984    ///
1985    /// Re-measured on the ninth. Declaring
1986    /// `TerminatedAfterRegistrationTimeout` and changing nothing else produced
1987    /// exactly one error, `E0004` at `Display`'s match; writing that arm
1988    /// produced exactly one more, `E0004` here; writing this one left
1989    /// `cargo check --all-targets --workspace` clean. Two stops, in that order,
1990    /// and no third — which is what the note above each of them promises.
1991    fn earliest_state_producing(reason: &FailureReason) -> AttemptState {
1992        // The second place a new variant stops the compiler, and the last one.
1993        // `FailureReason::ALL` is a const array, so it errors nowhere at all:
1994        // add the variant to `ALL` and to the `cases` table below too, or the
1995        // suite goes green with it untested.
1996        match reason {
1997            // Step 5: the package is verified before the JIT request is made.
1998            FailureReason::RunnerPackageUnverified => AttemptState::Allocated,
1999            // Step 5: `generate-jitconfig` did not return a configuration.
2000            FailureReason::JitRequestFailed => AttemptState::Allocated,
2001            // Flow 4.4: a configuration arrived and was never claimed.
2002            FailureReason::JitExpired => AttemptState::JitReceived,
2003            // Step 6: the child process could not be spawned.
2004            FailureReason::ProcessStartFailed => AttemptState::JitReceived,
2005            // Edge case 7: GitHub refuses the registration on version grounds.
2006            FailureReason::RunnerVersionRejected => AttemptState::Starting,
2007            // Flow 2's "runner exit before job acceptance".
2008            FailureReason::ProcessExitedUnexpectedly => AttemptState::Starting,
2009            // The live-but-unregistered process past its startup deadline; see
2010            // the `S::Starting` / `G::NotRegistered` arm of `recovery_decision`.
2011            FailureReason::RegistrationTimedOut => AttemptState::Starting,
2012            // The same attempt one step later: still `starting`, because the
2013            // agent acts on the `Terminate` payload without moving the state
2014            // first, and the process it signalled is the one that never
2015            // registered.
2016            FailureReason::TerminatedAfterRegistrationTimeout => AttemptState::Starting,
2017            FailureReason::Other(_) => AttemptState::Busy,
2018        }
2019    }
2020
2021    #[test]
2022    fn all_failure_reasons_are_reachable_from_the_state_that_produces_them() {
2023        // `03-control-flows.md` flow 2 names most of these by name as conditions
2024        // the agent must record. Before the diagram amendment five were
2025        // unreachable: each occurs at a pre-registration state, and `allocated`,
2026        // `jit_received` and `starting` had no terminal edge, so `conclude`
2027        // answered `OutcomeUnreachable` from every state that could actually
2028        // have produced them. A `FailureReason` variant that nothing can reach
2029        // is a variant `g2` renders a match arm for and no test can cover.
2030        //
2031        // The table is written out rather than derived so each pairing carries
2032        // its reason; `earliest_state_producing` above is what makes a new
2033        // variant a compile error, and the two are cross-checked below.
2034        let cases: [(FailureReason, AttemptState); 9] = [
2035            // Step 5: the package is verified before the JIT request is made.
2036            (
2037                FailureReason::RunnerPackageUnverified,
2038                AttemptState::Allocated,
2039            ),
2040            // Step 5: `generate-jitconfig` did not return a configuration.
2041            (FailureReason::JitRequestFailed, AttemptState::Allocated),
2042            // Flow 4.4: a configuration arrived and was never claimed.
2043            (FailureReason::JitExpired, AttemptState::JitReceived),
2044            // Step 6: the child process could not be spawned.
2045            (FailureReason::ProcessStartFailed, AttemptState::JitReceived),
2046            // Edge case 7: GitHub refuses the registration on version grounds.
2047            (FailureReason::RunnerVersionRejected, AttemptState::Starting),
2048            // Flow 2's "runner exit before job acceptance".
2049            (
2050                FailureReason::ProcessExitedUnexpectedly,
2051                AttemptState::Starting,
2052            ),
2053            // A live runner that never reached GitHub inside its startup window.
2054            (FailureReason::RegistrationTimedOut, AttemptState::Starting),
2055            // The same runner after `e3` acted on the `Terminate` payload: the
2056            // attempt never left `starting`, so this is where it concludes from.
2057            (
2058                FailureReason::TerminatedAfterRegistrationTimeout,
2059                AttemptState::Starting,
2060            ),
2061            (
2062                FailureReason::Other("a reason b1 did not anticipate".into()),
2063                AttemptState::Busy,
2064            ),
2065        ];
2066
2067        // Every variant is covered exactly once. Three separate things have to
2068        // hold, and none of them is a length compared against itself:
2069        //
2070        //  * `FailureReason::ALL` and this table are the same size, so a variant
2071        //    added to one and not the other is caught;
2072        //  * every variant in `ALL` appears here -- by discriminant, because
2073        //    `Other`'s payload differs between the two lists;
2074        //  * each pairing agrees with `earliest_state_producing`, whose
2075        //    wildcard-free match is what stops this file compiling when a
2076        //    variant is added at all.
2077        assert_eq!(
2078            cases.len(),
2079            FailureReason::ALL.len(),
2080            "every FailureReason variant needs a state it is reachable from"
2081        );
2082        for listed in FailureReason::ALL {
2083            assert!(
2084                cases.iter().any(|(reason, _)| {
2085                    std::mem::discriminant(reason) == std::mem::discriminant(&listed)
2086                }),
2087                "{listed:?} is in FailureReason::ALL but has no case here"
2088            );
2089        }
2090        for (reason, from) in &cases {
2091            assert_eq!(
2092                earliest_state_producing(reason),
2093                *from,
2094                "{reason:?}: the table and the exhaustive match disagree"
2095            );
2096        }
2097
2098        for (reason, from) in cases {
2099            let mut attempt = attempt_in(from, 0);
2100            attempt
2101                .conclude(AttemptOutcome::failed(reason.clone()), ts(5))
2102                .unwrap_or_else(|e| panic!("{reason:?} must be recordable from {from}, got {e}"));
2103
2104            assert_eq!(attempt.state(), AttemptState::Failed);
2105            assert_eq!(attempt.terminal_at(), Some(ts(5)));
2106            assert_eq!(
2107                attempt.outcome(),
2108                Some(&AttemptOutcome::failed(reason.clone()))
2109            );
2110            assert!(
2111                !attempt.counts_against_capacity(),
2112                "{reason:?} from {from} must give the host capacity slot back; \
2113                 that is what the amendment was for"
2114            );
2115
2116            // And it survives the persistence gate, so the row `b2` writes for
2117            // it can be read back.
2118            let restored = RunnerAttempt::from_persisted(attempt.to_persisted())
2119                .unwrap_or_else(|e| panic!("{reason:?} from {from} must reload: {e}"));
2120            assert_eq!(restored, attempt);
2121        }
2122    }
2123
2124    // =======================================================================
2125    // Busy protection
2126    // =======================================================================
2127
2128    #[test]
2129    fn a_busy_attempt_cannot_be_cleaned() {
2130        // `04-subsystem-contracts.md`: "`busy` cannot transition to cleanup due
2131        // to a scale-down request."
2132        let mut busy = attempt_in(AttemptState::Busy, 0);
2133        let err = busy.clean(ts(1)).unwrap_err();
2134        assert_eq!(
2135            err,
2136            AttemptError::BusyCannotBeCleaned,
2137            "the refusal must be named, not a generic transition error, so that a \
2138             scale-down that tried it is legible in a log"
2139        );
2140        assert_eq!(busy.state(), AttemptState::Busy);
2141        assert!(busy.counts_against_capacity());
2142    }
2143
2144    #[test]
2145    fn only_a_terminal_attempt_can_be_cleaned() {
2146        for state in AttemptState::ALL {
2147            let mut attempt = attempt_in(state, 0);
2148            let result = attempt.clean(ts(1));
2149            if state.is_concluded() {
2150                assert!(result.is_ok(), "{state} is terminal and must be cleanable");
2151                assert_eq!(attempt.state(), AttemptState::Cleaned);
2152            } else {
2153                assert!(
2154                    result.is_err(),
2155                    "{state} is not terminal and must not be cleanable"
2156                );
2157                assert_eq!(attempt.state(), state);
2158            }
2159        }
2160    }
2161
2162    #[test]
2163    fn capacity_is_reclaimed_exactly_at_the_terminal_states() {
2164        for state in AttemptState::ALL {
2165            assert_eq!(
2166                attempt_in(state, 0).counts_against_capacity(),
2167                !state.is_terminal(),
2168                "{state}"
2169            );
2170        }
2171
2172        let attempts = vec![
2173            attempt_in(AttemptState::Allocated, 0),
2174            attempt_in(AttemptState::Starting, 0),
2175            attempt_in(AttemptState::Busy, 0),
2176            attempt_in(AttemptState::Finished, 0),
2177            attempt_in(AttemptState::Cleaned, 0),
2178        ];
2179        assert_eq!(active_count(&attempts), 3);
2180    }
2181
2182    #[test]
2183    fn the_per_policy_active_count_is_not_the_host_wide_one() {
2184        // Substituting either for the other is silent, so the difference is
2185        // pinned rather than left to the reader of two similar names.
2186        let mine = PolicyId::from_u128(1);
2187        let theirs = PolicyId::from_u128(2);
2188
2189        let mut attempts = vec![
2190            attempt_for(mine, AttemptState::Starting, 0),
2191            attempt_for(mine, AttemptState::Busy, 0),
2192            attempt_for(theirs, AttemptState::Busy, 0),
2193            attempt_for(theirs, AttemptState::Idle, 0),
2194            attempt_for(theirs, AttemptState::Allocated, 0),
2195        ];
2196
2197        assert_eq!(active_count(&attempts), 5, "the host-wide total, for D9");
2198        assert_eq!(active_count_for(mine, &attempts), 2, "this policy, for D7");
2199        assert_eq!(active_count_for(theirs, &attempts), 3);
2200        assert_eq!(
2201            active_count_for(PolicyId::from_u128(3), &attempts),
2202            0,
2203            "a policy with nothing in flight"
2204        );
2205
2206        // Terminal attempts drop out of the per-policy count on the same rule as
2207        // the host-wide one.
2208        attempts.push(attempt_for(mine, AttemptState::Finished, 0));
2209        attempts.push(attempt_for(mine, AttemptState::Cleaned, 0));
2210        assert_eq!(active_count_for(mine, &attempts), 2);
2211    }
2212
2213    // =======================================================================
2214    // Workspace allocation (D3, D5, D6)
2215    // =======================================================================
2216
2217    fn slot(n: u16) -> AttemptWorkspace {
2218        AttemptWorkspace::persistent_slot(std::num::NonZeroU16::new(n).expect("a positive slot"))
2219    }
2220
2221    /// The unremarkable outcome for a terminal state, so a fixture does not have
2222    /// to restate the state/outcome pairing the loader enforces.
2223    fn terminal_outcome(state: AttemptState) -> AttemptOutcome {
2224        match state {
2225            AttemptState::Failed => {
2226                AttemptOutcome::failed(FailureReason::ProcessExitedUnexpectedly)
2227            }
2228            AttemptState::Orphaned => AttemptOutcome::Orphaned,
2229            _ => AttemptOutcome::CompletedJob,
2230        }
2231    }
2232
2233    #[test]
2234    fn the_ordinary_constructor_still_allocates_a_disposable_workspace() {
2235        // D3: disposable mode remains the default, so every existing caller of
2236        // `allocate` keeps the cleanup behaviour it had.
2237        let attempt = RunnerAttempt::allocate(
2238            AttemptId::from_u128(1),
2239            PolicyId::from_u128(1),
2240            "runtime/p/a",
2241            ts(0),
2242        );
2243        assert_eq!(attempt.workspace(), AttemptWorkspace::Ephemeral);
2244        assert_eq!(attempt.workspace().slot(), None);
2245        assert!(!attempt.holds_slot_lease());
2246        assert_eq!(
2247            attempt.to_persisted().workspace_kind,
2248            WorkspaceKind::Ephemeral
2249        );
2250        assert_eq!(attempt.to_persisted().workspace_slot, None);
2251    }
2252
2253    #[test]
2254    fn a_persistent_attempt_journals_the_slot_it_leased() {
2255        let attempt = RunnerAttempt::allocate_in(
2256            AttemptId::from_u128(1),
2257            PolicyId::from_u128(1),
2258            "/srv/rman/acme/s2",
2259            slot(2),
2260            ts(0),
2261        );
2262        assert_eq!(attempt.workspace(), slot(2));
2263        assert_eq!(attempt.workspace().slot_number(), Some(2));
2264        assert_eq!(
2265            attempt.workspace().slot_directory_name().as_deref(),
2266            Some("s2")
2267        );
2268        assert_eq!(attempt.runtime_path(), Path::new("/srv/rman/acme/s2"));
2269        assert!(attempt.holds_slot_lease());
2270    }
2271
2272    #[test]
2273    fn the_workspace_kind_and_slot_do_not_change_after_allocation() {
2274        // `02-target-architecture.md`: "Neither may change after allocation."
2275        // There is no setter, so the property is proved by driving the whole
2276        // lifecycle and reading the value back at every step.
2277        let mut attempt = RunnerAttempt::allocate_in(
2278            AttemptId::from_u128(1),
2279            PolicyId::from_u128(1),
2280            "/srv/rman/acme/s1",
2281            slot(1),
2282            ts(0),
2283        );
2284        attempt
2285            .jit_received(ts(1))
2286            .expect("allocated -> jit_received");
2287        assert_eq!(attempt.workspace(), slot(1));
2288        attempt
2289            .started(4242, ts(2))
2290            .expect("jit_received -> starting");
2291        assert_eq!(attempt.workspace(), slot(1));
2292        attempt
2293            .registered_idle(73, ts(3))
2294            .expect("starting -> idle");
2295        assert_eq!(attempt.workspace(), slot(1));
2296        attempt.assigned_job(73, ts(4)).expect("idle -> busy");
2297        assert_eq!(attempt.workspace(), slot(1));
2298        attempt
2299            .conclude(AttemptOutcome::CompletedJob, ts(5))
2300            .expect("busy -> finished");
2301        assert_eq!(attempt.workspace(), slot(1));
2302        assert!(
2303            attempt.holds_slot_lease(),
2304            "a terminal attempt still holds its slot until cleanup succeeds"
2305        );
2306
2307        attempt.clean(ts(6)).expect("finished -> cleaned");
2308        assert_eq!(attempt.workspace(), slot(1));
2309        assert!(
2310            !attempt.holds_slot_lease(),
2311            "a cleaned attempt releases the slot for reuse"
2312        );
2313    }
2314
2315    #[test]
2316    fn an_uncleaned_terminal_attempt_keeps_its_lease() {
2317        // `04-security-recovery.md`: "Attempt remains not-cleaned and continues
2318        // to hold the slot through the unique lease index".
2319        for state in [
2320            AttemptState::Finished,
2321            AttemptState::Failed,
2322            AttemptState::Orphaned,
2323        ] {
2324            let mut fields = row(state, Some(terminal_outcome(state)));
2325            fields.workspace_kind = WorkspaceKind::Persistent;
2326            fields.workspace_slot = Some(3);
2327            let attempt = RunnerAttempt::from_persisted(fields).expect("a row the domain accepts");
2328            assert!(attempt.holds_slot_lease(), "state {state}");
2329        }
2330    }
2331
2332    #[test]
2333    fn a_workspace_allocation_round_trips_through_the_journal() {
2334        for workspace in [AttemptWorkspace::Ephemeral, slot(1), slot(u16::MAX)] {
2335            let attempt = RunnerAttempt::allocate_in(
2336                AttemptId::from_u128(1),
2337                PolicyId::from_u128(1),
2338                "runtime/p/a",
2339                workspace,
2340                ts(0),
2341            );
2342            let restored = RunnerAttempt::from_persisted(attempt.to_persisted())
2343                .expect("a row this crate wrote must load");
2344            assert_eq!(restored, attempt);
2345            assert_eq!(restored.workspace(), workspace);
2346        }
2347    }
2348
2349    #[test]
2350    fn a_journal_row_whose_workspace_columns_disagree_is_rejected() {
2351        // The kind decides which cleanup algorithm is legal, so an undecidable
2352        // pair must fail closed rather than fall back to the destructive branch.
2353        let mut persistent_without_slot = row(AttemptState::Allocated, None);
2354        persistent_without_slot.workspace_kind = WorkspaceKind::Persistent;
2355        assert_eq!(
2356            RunnerAttempt::from_persisted(persistent_without_slot),
2357            Err(AttemptError::Workspace(
2358                WorkspaceError::PersistentWithoutSlot
2359            ))
2360        );
2361
2362        let mut zero_slot = row(AttemptState::Allocated, None);
2363        zero_slot.workspace_kind = WorkspaceKind::Persistent;
2364        zero_slot.workspace_slot = Some(0);
2365        assert_eq!(
2366            RunnerAttempt::from_persisted(zero_slot),
2367            Err(AttemptError::Workspace(WorkspaceError::SlotNotPositive))
2368        );
2369
2370        let mut ephemeral_with_slot = row(AttemptState::Allocated, None);
2371        ephemeral_with_slot.workspace_slot = Some(1);
2372        assert_eq!(
2373            RunnerAttempt::from_persisted(ephemeral_with_slot),
2374            Err(AttemptError::Workspace(WorkspaceError::EphemeralWithSlot {
2375                slot: 1
2376            }))
2377        );
2378    }
2379
2380    #[test]
2381    fn an_attempt_serialises_its_workspace_without_credentials() {
2382        let attempt = RunnerAttempt::allocate_in(
2383            AttemptId::from_u128(1),
2384            PolicyId::from_u128(1),
2385            "/srv/rman/acme/s2",
2386            slot(2),
2387            ts(0),
2388        );
2389        let encoded = serde_json::to_string(&attempt).expect("serialisable");
2390        let decoded: RunnerAttempt = serde_json::from_str(&encoded).expect("deserialisable");
2391        assert_eq!(decoded, attempt);
2392        for needle in ["token", "secret", "jitconfig", "password"] {
2393            assert!(
2394                !encoded.to_ascii_lowercase().contains(needle),
2395                "an attempt leaked {needle:?}: {encoded}"
2396            );
2397        }
2398    }
2399
2400    // =======================================================================
2401    // Persistence gate
2402    // =======================================================================
2403
2404    /// A journal row the domain accepts, for a test to spoil one field of.
2405    fn row(state: AttemptState, outcome: Option<AttemptOutcome>) -> PersistedAttempt {
2406        PersistedAttempt {
2407            id: AttemptId::from_u128(1),
2408            policy_id: PolicyId::from_u128(1),
2409            github_runner_id: Some(73),
2410            state,
2411            outcome,
2412            process_id: Some(9),
2413            runtime_path: "runtime/p/a".into(),
2414            workspace_kind: WorkspaceKind::Ephemeral,
2415            workspace_slot: None,
2416            created_at: ts(0),
2417            terminal_at: state.is_terminal().then(|| ts(9)),
2418            last_state_change_at: ts(9),
2419        }
2420    }
2421
2422    #[test]
2423    fn a_hand_edited_journal_row_with_an_impossible_outcome_is_rejected() {
2424        assert!(
2425            RunnerAttempt::from_persisted(row(
2426                AttemptState::Finished,
2427                Some(AttemptOutcome::ExitedIdleWithoutWork)
2428            ))
2429            .is_ok()
2430        );
2431
2432        // Terminal with no outcome.
2433        assert!(matches!(
2434            RunnerAttempt::from_persisted(row(AttemptState::Failed, None)),
2435            Err(AttemptError::TerminalWithoutOutcome { .. })
2436        ));
2437
2438        // Non-terminal carrying one.
2439        assert!(matches!(
2440            RunnerAttempt::from_persisted(row(
2441                AttemptState::Busy,
2442                Some(AttemptOutcome::CompletedJob)
2443            )),
2444            Err(AttemptError::NonTerminalWithOutcome { .. })
2445        ));
2446
2447        // A `failed` row that claims to have run a job.
2448        assert!(matches!(
2449            RunnerAttempt::from_persisted(row(
2450                AttemptState::Failed,
2451                Some(AttemptOutcome::CompletedJob)
2452            )),
2453            Err(AttemptError::OutcomeStateMismatch { .. })
2454        ));
2455
2456        // `cleaned` keeps whichever outcome preceded it.
2457        assert!(
2458            RunnerAttempt::from_persisted(row(
2459                AttemptState::Cleaned,
2460                Some(AttemptOutcome::Orphaned)
2461            ))
2462            .is_ok()
2463        );
2464    }
2465
2466    #[test]
2467    fn a_hand_edited_journal_row_with_an_impossible_terminal_at_is_rejected() {
2468        // `terminal_at` is on the same footing as `outcome`: `conclude` is the
2469        // only writer of either and sets them together, so a row where
2470        // `state.is_terminal()` and `terminal_at.is_some()` disagree is one this
2471        // crate cannot have produced. Before this gate existed, a `finished` row
2472        // with `terminal_at: None` loaded cleanly and every reader of
2473        // `terminal_at()` saw an attempt that had never concluded.
2474        let mut terminal_without = row(AttemptState::Finished, Some(AttemptOutcome::CompletedJob));
2475        terminal_without.terminal_at = None;
2476        assert!(
2477            matches!(
2478                RunnerAttempt::from_persisted(terminal_without),
2479                Err(AttemptError::TerminalWithoutTimestamp {
2480                    state: AttemptState::Finished
2481                })
2482            ),
2483            "a terminal row with no terminal_at must be refused, exactly as a \
2484             terminal row with no outcome is"
2485        );
2486
2487        // The other direction: a live attempt that claims to have concluded.
2488        let mut live_with = row(AttemptState::Busy, None);
2489        live_with.terminal_at = Some(ts(9));
2490        assert!(matches!(
2491            RunnerAttempt::from_persisted(live_with),
2492            Err(AttemptError::NonTerminalWithTimestamp {
2493                state: AttemptState::Busy
2494            })
2495        ));
2496
2497        // `cleaned` follows a concluded state, so it keeps that state's
2498        // timestamp and is not a special case.
2499        let mut cleaned = row(AttemptState::Cleaned, Some(AttemptOutcome::Orphaned));
2500        cleaned.terminal_at = Some(ts(4));
2501        assert!(RunnerAttempt::from_persisted(cleaned).is_ok());
2502    }
2503
2504    #[test]
2505    fn a_hand_edited_journal_row_whose_timestamps_run_backwards_is_rejected() {
2506        // Presence was already gated; ordering was not, so a row saying an
2507        // attempt concluded a hundred seconds before it was created loaded
2508        // cleanly and every duration derived from the pair came out negative.
2509        // The same hand-edited-journal threat model that motivates the presence
2510        // gate covers this, and `b2` is the task that will meet it.
2511        let mut concluded_before_created =
2512            row(AttemptState::Finished, Some(AttemptOutcome::CompletedJob));
2513        concluded_before_created.created_at = ts(100);
2514        concluded_before_created.last_state_change_at = ts(100);
2515        concluded_before_created.terminal_at = Some(ts(0));
2516        assert_eq!(
2517            RunnerAttempt::from_persisted(concluded_before_created),
2518            Err(AttemptError::TimestampsOutOfOrder {
2519                state: AttemptState::Finished,
2520                field: "terminal_at",
2521                created_at: ts(100),
2522                found: ts(0),
2523            })
2524        );
2525
2526        // The same for the timestamp every recovery timeout is measured from. A
2527        // `last_state_change_at` before `created_at` makes `now - it` larger
2528        // than the attempt's whole life, so every timeout reads as expired.
2529        let mut changed_before_created = row(AttemptState::Busy, None);
2530        changed_before_created.created_at = ts(100);
2531        changed_before_created.last_state_change_at = ts(0);
2532        assert_eq!(
2533            RunnerAttempt::from_persisted(changed_before_created),
2534            Err(AttemptError::TimestampsOutOfOrder {
2535                state: AttemptState::Busy,
2536                field: "last_state_change_at",
2537                created_at: ts(100),
2538                found: ts(0),
2539            })
2540        );
2541
2542        // Equal is not out of order: `allocate` writes the same instant to both,
2543        // so the very first row of every attempt has `created_at ==
2544        // last_state_change_at`, and an attempt concluded in the same second it
2545        // was allocated has all three equal.
2546        let mut same_instant = row(AttemptState::Failed, Some(AttemptOutcome::Orphaned));
2547        same_instant.outcome = Some(AttemptOutcome::failed(FailureReason::JitRequestFailed));
2548        same_instant.created_at = ts(7);
2549        same_instant.last_state_change_at = ts(7);
2550        same_instant.terminal_at = Some(ts(7));
2551        assert!(RunnerAttempt::from_persisted(same_instant).is_ok());
2552
2553        // And a well-ordered row is untouched by the new arm.
2554        assert!(
2555            RunnerAttempt::from_persisted(row(
2556                AttemptState::Finished,
2557                Some(AttemptOutcome::CompletedJob)
2558            ))
2559            .is_ok()
2560        );
2561    }
2562
2563    #[test]
2564    fn an_attempt_round_trips_through_its_persisted_form() {
2565        let mut attempt = RunnerAttempt::allocate(
2566            AttemptId::from_u128(3),
2567            PolicyId::from_u128(4),
2568            "runtime/p/a",
2569            ts(0),
2570        );
2571        attempt.jit_received(ts(1)).unwrap();
2572        attempt.started(7, ts(2)).unwrap();
2573        attempt.registered_idle(7, ts(3)).unwrap();
2574        attempt
2575            .conclude(AttemptOutcome::ExitedIdleWithoutWork, ts(4))
2576            .unwrap();
2577
2578        let restored = RunnerAttempt::from_persisted(attempt.to_persisted())
2579            .expect("a row this crate produced must load");
2580        assert_eq!(restored, attempt);
2581        assert_eq!(restored.terminal_at(), Some(ts(4)));
2582        assert_ne!(
2583            restored.last_state_change_at(),
2584            restored.created_at,
2585            "the two timestamps must not collapse onto each other; that \
2586             transposition is what PersistedAttempt exists to prevent"
2587        );
2588    }
2589
2590    #[test]
2591    fn an_attempt_round_trips_through_serde() {
2592        let mut attempt = RunnerAttempt::allocate(
2593            AttemptId::from_u128(3),
2594            PolicyId::from_u128(4),
2595            "runtime/p/a",
2596            ts(0),
2597        );
2598        attempt.jit_received(ts(1)).unwrap();
2599        attempt.started(7, ts(2)).unwrap();
2600        attempt.registered_idle(73, ts(3)).unwrap();
2601        attempt
2602            .conclude(
2603                AttemptOutcome::failed(FailureReason::Other("no detail".into())),
2604                ts(4),
2605            )
2606            .unwrap();
2607
2608        let json = serde_json::to_string(&attempt).unwrap();
2609        let back: RunnerAttempt = serde_json::from_str(&json).unwrap();
2610        assert_eq!(attempt, back);
2611    }
2612
2613    // =======================================================================
2614    // Ownership
2615    // =======================================================================
2616
2617    #[test]
2618    fn an_attempt_belonging_to_another_host_is_rejected() {
2619        // Ownership rule 2: "A host agent may act only on attempts persisted
2620        // under its `host_id`."
2621        let mine = HostId::from_u128(7);
2622        let theirs = HostId::from_u128(8);
2623        let policy_id = PolicyId::from_u128(11);
2624
2625        let their_policy = a_policy(theirs, policy_id);
2626        let attempt =
2627            RunnerAttempt::allocate(AttemptId::from_u128(1), policy_id, "runtime/p/a", ts(0));
2628
2629        let err = authorize(mine, &their_policy, &attempt).unwrap_err();
2630        assert!(
2631            matches!(
2632                err,
2633                OwnershipError::ForeignHost {
2634                    owner,
2635                    agent,
2636                    ..
2637                } if owner == theirs && agent == mine
2638            ),
2639            "got {err:?}"
2640        );
2641
2642        // The same attempt under our own policy is fine.
2643        let my_policy = a_policy(mine, policy_id);
2644        assert!(authorize(mine, &my_policy, &attempt).is_ok());
2645    }
2646
2647    #[test]
2648    fn an_attempt_checked_against_the_wrong_policy_is_rejected() {
2649        let host = HostId::from_u128(7);
2650        let policy = a_policy(host, PolicyId::from_u128(11));
2651        let attempt = RunnerAttempt::allocate(
2652            AttemptId::from_u128(1),
2653            PolicyId::from_u128(12),
2654            "runtime/p/a",
2655            ts(0),
2656        );
2657        assert!(matches!(
2658            authorize(host, &policy, &attempt),
2659            Err(OwnershipError::PolicyMismatch { .. })
2660        ));
2661    }
2662
2663    // =======================================================================
2664    // Recovery, against a controlled clock
2665    // =======================================================================
2666
2667    #[test]
2668    fn recovery_never_reads_the_system_clock() {
2669        // The whole decision surface moves when the fake clock moves, and by
2670        // nothing else. If any branch called `Utc::now()` the two decisions below
2671        // would be identical.
2672        let timeouts = RecoveryTimeouts::new(
2673            Elapsed::seconds(60),
2674            Elapsed::seconds(120),
2675            Elapsed::seconds(300),
2676        );
2677        let attempt = attempt_in(AttemptState::Idle, 1_000);
2678        let observation = RecoveryObservation {
2679            process_alive: false,
2680            github: GithubRunnerObservation::NotRegistered,
2681        };
2682
2683        let clock = StubClock::at(1_000 + 299);
2684        assert_eq!(
2685            recovery_decision(&attempt, observation, timeouts, &clock),
2686            RecoveryDecision::Conclude(AttemptOutcome::failed(
2687                FailureReason::ProcessExitedUnexpectedly
2688            )),
2689            "one second before its idle timeout, a vanished runner crashed"
2690        );
2691
2692        clock.set(1_000 + 300);
2693        assert_eq!(
2694            recovery_decision(&attempt, observation, timeouts, &clock),
2695            RecoveryDecision::Conclude(AttemptOutcome::ExitedIdleWithoutWork),
2696            "at its idle timeout, the same runner is the surplus case from flow 2.7"
2697        );
2698    }
2699
2700    #[test]
2701    fn a_live_process_is_adopted_rather_than_duplicated() {
2702        // `e3`: "an attempt whose process still runs is adopted, not duplicated."
2703        let timeouts = RecoveryTimeouts::provisional();
2704        let clock = StubClock::at(1_000_000);
2705        for state in [
2706            AttemptState::Allocated,
2707            AttemptState::JitReceived,
2708            AttemptState::Starting,
2709            AttemptState::Idle,
2710            AttemptState::Busy,
2711        ] {
2712            // `entered_at` is the clock's own instant, so every state is inside
2713            // its window and the only thing being asserted is the live-process
2714            // rule.
2715            let attempt = attempt_in(state, 1_000_000);
2716            assert_eq!(
2717                recovery_decision(
2718                    &attempt,
2719                    RecoveryObservation {
2720                        process_alive: true,
2721                        github: GithubRunnerObservation::NotRegistered,
2722                    },
2723                    timeouts,
2724                    &clock,
2725                ),
2726                RecoveryDecision::Adopt,
2727                "{state} with a live process"
2728            );
2729        }
2730    }
2731
2732    #[test]
2733    fn a_dead_busy_attempt_is_orphaned_rather_than_guessed_into_a_success() {
2734        // The agent never reports a job as complete; GitHub remains the source of
2735        // truth for workflow outcome (flow 2, Failure).
2736        let clock = StubClock::at(1_000_000);
2737        for github in [
2738            GithubRunnerObservation::NotRegistered,
2739            GithubRunnerObservation::Registered { busy: true },
2740            GithubRunnerObservation::Registered { busy: false },
2741        ] {
2742            assert_eq!(
2743                recovery_decision(
2744                    &attempt_in(AttemptState::Busy, 0),
2745                    RecoveryObservation {
2746                        process_alive: false,
2747                        github,
2748                    },
2749                    RecoveryTimeouts::provisional(),
2750                    &clock,
2751                ),
2752                RecoveryDecision::Conclude(AttemptOutcome::Orphaned),
2753                "{github:?}"
2754            );
2755        }
2756    }
2757
2758    #[test]
2759    fn an_unreachable_github_defers_every_decision() {
2760        // Flow 3.3: while offline, start nothing and retain what is running. An
2761        // agent that treated "unreachable" as "not registered" would conclude and
2762        // clean every live attempt during a network outage.
2763        let clock = StubClock::at(1_000_000);
2764        for state in [
2765            AttemptState::Allocated,
2766            AttemptState::JitReceived,
2767            AttemptState::Starting,
2768            AttemptState::Idle,
2769            AttemptState::Busy,
2770        ] {
2771            assert_eq!(
2772                recovery_decision(
2773                    &attempt_in(state, 0),
2774                    RecoveryObservation {
2775                        process_alive: false,
2776                        github: GithubRunnerObservation::Unreachable,
2777                    },
2778                    RecoveryTimeouts::provisional(),
2779                    &clock,
2780                ),
2781                RecoveryDecision::Defer,
2782                "{state} while GitHub is unreachable"
2783            );
2784        }
2785    }
2786
2787    #[test]
2788    fn a_starting_attempt_follows_what_github_reports() {
2789        // Precedence rule 3: GitHub runner status is authoritative for remote job
2790        // status. Both edges are in the diagram.
2791        let clock = StubClock::at(1_000);
2792        let attempt = attempt_in(AttemptState::Starting, 0);
2793
2794        assert_eq!(
2795            recovery_decision(
2796                &attempt,
2797                RecoveryObservation {
2798                    process_alive: true,
2799                    github: GithubRunnerObservation::Registered { busy: true },
2800                },
2801                RecoveryTimeouts::provisional(),
2802                &clock,
2803            ),
2804            RecoveryDecision::Observe(AttemptState::Busy)
2805        );
2806        assert_eq!(
2807            recovery_decision(
2808                &attempt,
2809                RecoveryObservation {
2810                    process_alive: true,
2811                    github: GithubRunnerObservation::Registered { busy: false },
2812                },
2813                RecoveryTimeouts::provisional(),
2814                &clock,
2815            ),
2816            RecoveryDecision::Observe(AttemptState::Idle)
2817        );
2818    }
2819
2820    #[test]
2821    fn a_pre_registration_attempt_believes_github_over_its_own_stale_journal() {
2822        // The crash window `e3` exists for: the process started and registered
2823        // at GitHub, but the write recording it was lost. GitHub is telling the
2824        // agent the runner is alive.
2825        //
2826        // This branch used to consult only `process_alive` and the clock, so the
2827        // observation below produced
2828        // `NoLegalTransition { from: JitReceived, wanted: Failed }` -- the
2829        // attempt was abandoned as failed while its registration stayed at
2830        // GitHub, never reconciled and never removed. Nothing in the suite
2831        // noticed, because nothing asked.
2832        let timeouts = RecoveryTimeouts::new(
2833            Elapsed::seconds(60),
2834            Elapsed::seconds(120),
2835            Elapsed::seconds(300),
2836        );
2837        // Well past the JIT handoff deadline, so the old code's timeout arm is
2838        // the one being displaced.
2839        let clock = StubClock::at(10_000);
2840
2841        for busy in [true, false] {
2842            assert_eq!(
2843                recovery_decision(
2844                    &attempt_in(AttemptState::JitReceived, 0),
2845                    RecoveryObservation {
2846                        process_alive: false,
2847                        github: GithubRunnerObservation::Registered { busy },
2848                    },
2849                    timeouts,
2850                    &clock,
2851                ),
2852                RecoveryDecision::Observe(AttemptState::Starting),
2853                "jit_received + Registered{{busy:{busy}}}: the runner cannot have \
2854                 registered without starting, and `jit_received -> starting` is \
2855                 an edge the diagram already has"
2856            );
2857
2858            // `allocated` has no `-> starting` edge, so it takes the one legal
2859            // step it does have. The registration proves the JIT configuration
2860            // arrived, which is exactly what that edge records; the next pass
2861            // continues from `jit_received`.
2862            assert_eq!(
2863                recovery_decision(
2864                    &attempt_in(AttemptState::Allocated, 0),
2865                    RecoveryObservation {
2866                        process_alive: false,
2867                        github: GithubRunnerObservation::Registered { busy },
2868                    },
2869                    timeouts,
2870                    &clock,
2871                ),
2872                RecoveryDecision::Observe(AttemptState::JitReceived),
2873                "allocated + Registered{{busy:{busy}}}"
2874            );
2875        }
2876
2877        // Unchanged where GitHub knows nothing: the timeout still decides, and a
2878        // live process is still adopted rather than duplicated. What changed at
2879        // the amendment is only what the timeout produces -- a conclusion
2880        // instead of a report that none was expressible.
2881        assert_eq!(
2882            recovery_decision(
2883                &attempt_in(AttemptState::JitReceived, 0),
2884                RecoveryObservation {
2885                    process_alive: false,
2886                    github: GithubRunnerObservation::NotRegistered,
2887                },
2888                timeouts,
2889                &clock,
2890            ),
2891            RecoveryDecision::Conclude(AttemptOutcome::failed(FailureReason::JitExpired))
2892        );
2893        assert_eq!(
2894            recovery_decision(
2895                &attempt_in(AttemptState::JitReceived, 0),
2896                RecoveryObservation {
2897                    process_alive: true,
2898                    github: GithubRunnerObservation::Registered { busy: false },
2899                },
2900                timeouts,
2901                &clock,
2902            ),
2903            RecoveryDecision::Adopt,
2904            "precedence rule 3's other half: local process state is \
2905             authoritative for a child process this agent owns"
2906        );
2907    }
2908
2909    #[test]
2910    fn a_terminal_attempt_is_cleaned_and_a_cleaned_one_is_left_alone() {
2911        let clock = StubClock::at(1_000_000);
2912        let observation = RecoveryObservation {
2913            process_alive: false,
2914            github: GithubRunnerObservation::NotRegistered,
2915        };
2916        for state in [
2917            AttemptState::Finished,
2918            AttemptState::Failed,
2919            AttemptState::Orphaned,
2920        ] {
2921            assert_eq!(
2922                recovery_decision(
2923                    &attempt_in(state, 0),
2924                    observation,
2925                    RecoveryTimeouts::provisional(),
2926                    &clock
2927                ),
2928                RecoveryDecision::Clean,
2929                "{state}"
2930            );
2931        }
2932        assert_eq!(
2933            recovery_decision(
2934                &attempt_in(AttemptState::Cleaned, 0),
2935                observation,
2936                RecoveryTimeouts::provisional(),
2937                &clock
2938            ),
2939            RecoveryDecision::Nothing
2940        );
2941    }
2942
2943    #[test]
2944    fn a_pre_registration_attempt_waits_until_its_deadline_then_concludes() {
2945        // This test used to document a gap in the state diagram: `allocated`,
2946        // `jit_received` and `starting` had no edge to `failed` or `orphaned`,
2947        // so a dead attempt that never registered could not be concluded at all
2948        // and the decision reported the missing edge instead of inventing one.
2949        // The 2026-08-21 amendment added those edges, and the whole point of
2950        // adding them was that this attempt now gives its capacity slot back.
2951        let timeouts = RecoveryTimeouts::new(
2952            Elapsed::seconds(60),
2953            Elapsed::seconds(120),
2954            Elapsed::seconds(300),
2955        );
2956        let clock = StubClock::at(1_059);
2957        let observation = RecoveryObservation {
2958            process_alive: false,
2959            github: GithubRunnerObservation::NotRegistered,
2960        };
2961
2962        // The two pre-JIT states conclude with different reasons, because they
2963        // know different things: at `allocated` no configuration was ever
2964        // recorded as arriving, at `jit_received` one arrived and was never
2965        // claimed.
2966        for (state, reason) in [
2967            (AttemptState::Allocated, FailureReason::JitRequestFailed),
2968            (AttemptState::JitReceived, FailureReason::JitExpired),
2969        ] {
2970            let attempt = attempt_in(state, 1_000);
2971            assert_eq!(
2972                recovery_decision(&attempt, observation, timeouts, &clock),
2973                RecoveryDecision::Wait,
2974                "{state} inside its handoff window"
2975            );
2976            clock.set(1_060);
2977            assert_eq!(
2978                recovery_decision(&attempt, observation, timeouts, &clock),
2979                RecoveryDecision::Conclude(AttemptOutcome::failed(reason)),
2980                "{state} past its handoff window concludes rather than stranding \
2981                 the attempt"
2982            );
2983            clock.set(1_059);
2984        }
2985
2986        // `starting` past its own, longer deadline: flow 2's "runner exit before
2987        // job acceptance".
2988        clock.set(1_121);
2989        assert_eq!(
2990            recovery_decision(
2991                &attempt_in(AttemptState::Starting, 1_000),
2992                observation,
2993                timeouts,
2994                &clock
2995            ),
2996            RecoveryDecision::Conclude(AttemptOutcome::failed(
2997                FailureReason::ProcessExitedUnexpectedly
2998            ))
2999        );
3000
3001        // Every one of those decisions must be applicable to the attempt it was
3002        // made about; a decision the state machine then refuses would strand the
3003        // attempt just as the missing edges did.
3004        for (state, decision) in [
3005            (
3006                AttemptState::Allocated,
3007                AttemptOutcome::failed(FailureReason::JitRequestFailed),
3008            ),
3009            (
3010                AttemptState::JitReceived,
3011                AttemptOutcome::failed(FailureReason::JitExpired),
3012            ),
3013            (
3014                AttemptState::Starting,
3015                AttemptOutcome::failed(FailureReason::ProcessExitedUnexpectedly),
3016            ),
3017        ] {
3018            let mut attempt = attempt_in(state, 1_000);
3019            attempt
3020                .conclude(decision, ts(1_200))
3021                .unwrap_or_else(|e| panic!("recovery decided {state} concludes, but: {e}"));
3022            assert!(!attempt.counts_against_capacity());
3023        }
3024    }
3025
3026    #[test]
3027    fn an_idle_runner_that_github_reports_as_busy_is_recorded_as_busy() {
3028        // The `idle -> busy` half of the amendment, at the point it bites in
3029        // recovery. GitHub is authoritative for remote job status (precedence
3030        // rule 3), and before the amendment this observation was not
3031        // representable at all: the decision reported that no legal transition
3032        // existed and the attempt stayed `idle` for ever while a job ran on it.
3033        let timeouts = RecoveryTimeouts::provisional();
3034        let clock = StubClock::at(1_121);
3035        assert_eq!(
3036            recovery_decision(
3037                &attempt_in(AttemptState::Idle, 1_000),
3038                RecoveryObservation {
3039                    process_alive: false,
3040                    github: GithubRunnerObservation::Registered { busy: true },
3041                },
3042                timeouts,
3043                &clock,
3044            ),
3045            RecoveryDecision::Observe(AttemptState::Busy)
3046        );
3047
3048        // And the decision is applicable: `idle -> busy` is an edge, so the
3049        // caller can actually carry it out.
3050        let mut attempt = attempt_in(AttemptState::Idle, 1_000);
3051        attempt.assigned_job(73, ts(1_200)).unwrap();
3052        assert_eq!(attempt.state(), AttemptState::Busy);
3053        assert_eq!(attempt.github_runner_id(), Some(73));
3054
3055        // And a live process does not change the answer. GitHub is authoritative
3056        // for remote job status; the caller adopts supervision independently of
3057        // the decision, which is what `Observe`'s own documentation says and
3058        // what `starting` has always relied on.
3059        assert_eq!(
3060            recovery_decision(
3061                &attempt_in(AttemptState::Idle, 1_000),
3062                RecoveryObservation {
3063                    process_alive: true,
3064                    github: GithubRunnerObservation::Registered { busy: true },
3065                },
3066                timeouts,
3067                &clock,
3068            ),
3069            RecoveryDecision::Observe(AttemptState::Busy),
3070            "a live process must not hide a job GitHub is reporting"
3071        );
3072    }
3073
3074    #[test]
3075    fn a_restart_during_a_job_is_recorded_as_busy_and_not_left_reading_idle() {
3076        // A1. The same conflict -- a live process that GitHub reports as `busy`
3077        // -- must resolve the same way at `idle` as at `starting`, and this test
3078        // is written as the damage rather than as the symmetry, because the
3079        // symmetry is not what it costs to get wrong.
3080        //
3081        // Before the fix, `idle` short-circuited on `process_alive` and answered
3082        // `Adopt`. `Adopt` writes nothing, so the journal stayed `idle` and
3083        // `last_state_change_at` stayed pointed at the idle entry. Ten minutes
3084        // later the runner crashed mid-job, GitHub reaped its registration, and
3085        // the idle timeout had long since elapsed -- so the crash was concluded
3086        // `ExitedIdleWithoutWork`: the benign surplus exit, which `g2` renders
3087        // as a normal end and never alarms an operator about. Nothing
3088        // downstream could refuse it, because `required_from` for that outcome
3089        // is `&[Idle]` and the attempt genuinely was `idle`.
3090        let timeouts = RecoveryTimeouts::new(
3091            Elapsed::seconds(60),
3092            Elapsed::seconds(120),
3093            Elapsed::seconds(300),
3094        );
3095        let clock = StubClock::at(1_000);
3096
3097        // The two arms answer alike, which is the property. `starting` was
3098        // already right; `idle` was not.
3099        let mid_job = RecoveryObservation {
3100            process_alive: true,
3101            github: GithubRunnerObservation::Registered { busy: true },
3102        };
3103        assert_eq!(
3104            recovery_decision(
3105                &attempt_in(AttemptState::Idle, 1_000),
3106                mid_job,
3107                timeouts,
3108                &clock
3109            ),
3110            recovery_decision(
3111                &attempt_in(AttemptState::Starting, 1_000),
3112                mid_job,
3113                timeouts,
3114                &clock
3115            ),
3116            "the same observation must not resolve one way at idle and the \
3117             opposite way one state earlier"
3118        );
3119        assert_eq!(
3120            recovery_decision(
3121                &attempt_in(AttemptState::Idle, 1_000),
3122                mid_job,
3123                timeouts,
3124                &clock
3125            ),
3126            RecoveryDecision::Observe(AttemptState::Busy)
3127        );
3128
3129        // And now the consequence, walked end to end. The caller applies the
3130        // decision -- which `Observe` requires, on pain of repeating for ever --
3131        // and the attempt is `busy` with `last_state_change_at` moved to the
3132        // moment the job was observed.
3133        let mut attempt = attempt_in(AttemptState::Idle, 1_000);
3134        attempt.assigned_job(73, ts(1_000)).expect("idle -> busy");
3135        assert_eq!(attempt.last_state_change_at(), ts(1_000));
3136
3137        // Ten minutes on, the process is gone and GitHub has reaped the runner.
3138        // Well past the idle timeout, and irrelevant: a crash from `busy` is
3139        // `Orphaned`, and `ExitedIdleWithoutWork` is not reachable from `busy`
3140        // at all.
3141        clock.set(1_600);
3142        let crashed = RecoveryObservation {
3143            process_alive: false,
3144            github: GithubRunnerObservation::NotRegistered,
3145        };
3146        let decision = recovery_decision(&attempt, crashed, timeouts, &clock);
3147        assert_eq!(
3148            decision,
3149            RecoveryDecision::Conclude(AttemptOutcome::Orphaned),
3150            "a crash during a job is a lost supervision, never the surplus exit"
3151        );
3152        let RecoveryDecision::Conclude(outcome) = decision else {
3153            unreachable!("asserted just above")
3154        };
3155        assert!(
3156            outcome.is_failure() && !outcome.is_idle_exit(),
3157            "`g2` reads these two flags to decide whether to alarm an operator, \
3158             and a mid-job crash must alarm one"
3159        );
3160
3161        // The old behaviour, spelled out so the regression is unmistakable: had
3162        // the attempt been left at `idle`, this is what the same crash would
3163        // have produced.
3164        assert_eq!(
3165            recovery_decision(
3166                &attempt_in(AttemptState::Idle, 1_000),
3167                crashed,
3168                timeouts,
3169                &clock
3170            ),
3171            RecoveryDecision::Conclude(AttemptOutcome::ExitedIdleWithoutWork),
3172            "which is exactly why the journal must not be left saying `idle`"
3173        );
3174    }
3175
3176    #[test]
3177    fn a_registered_runner_that_never_gets_a_job_is_stopped_at_its_idle_timeout() {
3178        // The surplus case of flow 2.7, in the shape it actually occurs in:
3179        // GitHub still lists the runner, it is not busy, and the process is
3180        // very much alive -- `Runner.Listener run` long-polls for an assignment
3181        // and has no idle timeout of its own, so it never leaves on its own.
3182        // This arm answered `Adopt` at every elapsed time, which is why a runner
3183        // observed in the field held its slot and its row in the target's runner
3184        // settings for 27 hours.
3185        let timeouts = RecoveryTimeouts::new(
3186            Elapsed::seconds(60),
3187            Elapsed::seconds(120),
3188            Elapsed::seconds(300),
3189        );
3190        let clock = StubClock::at(1_000);
3191        let attempt = attempt_in(AttemptState::Idle, 1_000);
3192        let idle_registered = |alive| RecoveryObservation {
3193            process_alive: alive,
3194            github: GithubRunnerObservation::Registered { busy: false },
3195        };
3196
3197        // Inside the window the runner is still plausibly about to be assigned,
3198        // so it is adopted and nothing is disturbed.
3199        clock.set(1_299);
3200        assert_eq!(
3201            recovery_decision(&attempt, idle_registered(true), timeouts, &clock),
3202            RecoveryDecision::Adopt,
3203            "a runner one second inside its idle timeout may still be given a job"
3204        );
3205
3206        // At the deadline it is surplus, and the agent has to end it.
3207        clock.set(1_300);
3208        let decision = recovery_decision(&attempt, idle_registered(true), timeouts, &clock);
3209        assert_eq!(
3210            decision,
3211            RecoveryDecision::Terminate(AttemptOutcome::ExitedIdleWithoutWork),
3212            "past the idle timeout a registered, unassigned runner is flow 2.7's surplus case"
3213        );
3214
3215        // `Terminate`, not `Conclude`, and the difference is the capacity slot:
3216        // the process is alive, so the slot may not come back until it is gone.
3217        let mut attempt = attempt;
3218        assert!(attempt.counts_against_capacity());
3219        let RecoveryDecision::Terminate(outcome) = decision else {
3220            unreachable!("asserted above")
3221        };
3222        assert!(
3223            outcome.is_idle_exit(),
3224            "the surplus exit is a normal outcome, and `g2` renders it as one -- an operator \
3225             sent to hunt a fault here would find nothing"
3226        );
3227        attempt
3228            .conclude(outcome, ts(1_310))
3229            .expect("the payload must be applicable to the attempt it was made about");
3230        assert!(!attempt.counts_against_capacity());
3231
3232        // A dead process is the pre-existing reading and is left alone: the
3233        // registration outlived supervision, which is what `Orphaned` names.
3234        clock.set(1_400);
3235        assert_eq!(
3236            recovery_decision(
3237                &attempt_in(AttemptState::Idle, 1_000),
3238                idle_registered(false),
3239                timeouts,
3240                &clock
3241            ),
3242            RecoveryDecision::Conclude(AttemptOutcome::Orphaned),
3243            "the idle deadline governs a live runner; a dead one is orphaned however long it sat"
3244        );
3245
3246        // And a runner GitHub reports busy is never stopped, at any elapsed
3247        // time. This is the assertion that stops the deadline above from ever
3248        // being applied to a runner in the middle of a job.
3249        for offset in [0, 299, 300, 100_000] {
3250            clock.set(1_000 + offset);
3251            assert_eq!(
3252                recovery_decision(
3253                    &attempt_in(AttemptState::Idle, 1_000),
3254                    RecoveryObservation {
3255                        process_alive: true,
3256                        github: GithubRunnerObservation::Registered { busy: true },
3257                    },
3258                    timeouts,
3259                    &clock
3260                ),
3261                RecoveryDecision::Observe(AttemptState::Busy),
3262                "at +{offset}s a runner that took a job was stopped by an idle deadline"
3263            );
3264        }
3265    }
3266
3267    #[test]
3268    fn a_live_unregistered_runner_past_its_deadline_is_stopped_before_its_slot_returns() {
3269        // A2. Three cases at `starting` with nothing at GitHub, and the middle
3270        // one used to be folded into the last.
3271        let timeouts = RecoveryTimeouts::new(
3272            Elapsed::seconds(60),
3273            Elapsed::seconds(120),
3274            Elapsed::seconds(300),
3275        );
3276        let clock = StubClock::at(1_000);
3277        let attempt = attempt_in(AttemptState::Starting, 1_000);
3278        let unregistered = |alive| RecoveryObservation {
3279            process_alive: alive,
3280            github: GithubRunnerObservation::NotRegistered,
3281        };
3282
3283        // Inside the window, alive: adopted.
3284        clock.set(1_119);
3285        assert_eq!(
3286            recovery_decision(&attempt, unregistered(true), timeouts, &clock),
3287            RecoveryDecision::Adopt
3288        );
3289
3290        // Gone: an accurate `ProcessExitedUnexpectedly`, and terminal, because
3291        // there is nothing left running to hold the slot.
3292        assert_eq!(
3293            recovery_decision(&attempt, unregistered(false), timeouts, &clock),
3294            RecoveryDecision::Conclude(AttemptOutcome::failed(
3295                FailureReason::ProcessExitedUnexpectedly
3296            )),
3297            "the one case that really is flow 2's runner exit"
3298        );
3299
3300        // Alive and past the deadline: neither of the above.
3301        clock.set(1_120);
3302        let decision = recovery_decision(&attempt, unregistered(true), timeouts, &clock);
3303        assert_eq!(
3304            decision,
3305            RecoveryDecision::Terminate(AttemptOutcome::failed(
3306                FailureReason::RegistrationTimedOut
3307            ))
3308        );
3309        assert_ne!(
3310            decision,
3311            RecoveryDecision::Conclude(AttemptOutcome::failed(
3312                FailureReason::ProcessExitedUnexpectedly
3313            )),
3314            "a process visible in a task manager did not exit unexpectedly, and \
3315             an operator told that it did stops believing the next message too"
3316        );
3317
3318        // The substance: the slot is not returned while the process runs. The
3319        // decision itself moves nothing, so the attempt is still `starting` and
3320        // still counted -- which is what stops the agent starting a replacement
3321        // beside a runner that could still register and take a job.
3322        let mut attempt = attempt;
3323        assert!(attempt.counts_against_capacity());
3324        assert_eq!(active_count([&attempt]), 1);
3325
3326        // The slot comes back at the moment the caller applies the payload,
3327        // which it does only after the process is gone.
3328        let RecoveryDecision::Terminate(outcome) = decision else {
3329            unreachable!("asserted above")
3330        };
3331        attempt
3332            .conclude(outcome, ts(1_130))
3333            .expect("the payload must be applicable to the attempt it was made about");
3334        assert_eq!(attempt.state(), AttemptState::Failed);
3335        assert!(!attempt.counts_against_capacity());
3336        assert_eq!(active_count([&attempt]), 0);
3337
3338        // Half the re-derivation is free: an agent that died between deciding
3339        // and terminating still sees a live process, and is handed the same
3340        // answer next pass.
3341        let pending = attempt_in(AttemptState::Starting, 1_000);
3342        assert_eq!(
3343            recovery_decision(&pending, unregistered(true), timeouts, &clock),
3344            RecoveryDecision::Terminate(AttemptOutcome::failed(
3345                FailureReason::RegistrationTimedOut
3346            ))
3347        );
3348
3349        // The other half is not, and this is the assertion that says so rather
3350        // than blessing it. An agent that terminated the process and died
3351        // before writing the outcome is, on the next pass, indistinguishable
3352        // from one whose runner crashed on its own: both present exactly the
3353        // same `RecoveryObservation`. The decision below is therefore the same
3354        // for both, and for the terminate-then-crash case the reason it carries
3355        // is wrong -- nothing exited unexpectedly, this agent killed it.
3356        let crashed_on_its_own = unregistered(false);
3357        let killed_by_us_then_lost = unregistered(false);
3358        assert_eq!(
3359            crashed_on_its_own, killed_by_us_then_lost,
3360            "this equality *is* the defect: the fact that separates the two \
3361             cases is not an input to `recovery_decision`, which is why it \
3362             cannot be repaired inside it"
3363        );
3364        assert_eq!(
3365            recovery_decision(&pending, killed_by_us_then_lost, timeouts, &clock),
3366            RecoveryDecision::Conclude(AttemptOutcome::failed(
3367                FailureReason::ProcessExitedUnexpectedly
3368            )),
3369            "pinned as the current behaviour of a known-wrong window, not as a \
3370             correct answer: `e3` closes it by journalling terminate-intent \
3371             before signalling and concluding the marked attempt with \
3372             `TerminatedAfterRegistrationTimeout`, which is true of the dead \
3373             process this pass is looking at. `RecoveryDecision::Terminate` \
3374             names the trade and the owner"
3375        );
3376    }
3377
3378    #[test]
3379    fn no_decision_calls_a_dead_process_live() {
3380        // `RegistrationTimedOut` renders as "the runner process is running but
3381        // did not register", `ProcessExitedUnexpectedly` is documented "only for
3382        // a process that is actually gone", and
3383        // `TerminatedAfterRegistrationTimeout` says the agent stopped the
3384        // process. All three are claims about liveness, and
3385        // `RecoveryObservation::process_alive` is the only source of truth for
3386        // it, so each reason may only ever appear beside the observation that
3387        // supports it.
3388        //
3389        // This is also the guard on the `Terminate` window's residual. The
3390        // rejected fix for that window -- reach for `RegistrationTimedOut` in
3391        // the dead-process arm at `starting` once `elapsed >= startup` --
3392        // compiles, and reds three tests. Measured by applying it:
3393        //
3394        //  * here, at `starting at +120s with NotRegistered and a process the
3395        //    observation says is gone was reported as still running`;
3396        //  * `a_live_unregistered_runner_past_its_deadline_is_stopped_before_
3397        //    its_slot_returns`, at the assertion that pins the known-wrong
3398        //    window as current behaviour;
3399        //  * `a_pre_registration_attempt_waits_until_its_deadline_then_
3400        //    concludes`, with `left: Conclude(Failed { reason:
3401        //    RegistrationTimedOut })` against `right: Conclude(Failed { reason:
3402        //    ProcessExitedUnexpectedly })`.
3403        //
3404        // The third is the strongest of the three and is worth naming ahead of
3405        // this one. It is not a guard written to catch this mistake: it is a
3406        // real scenario already in the suite -- flow 2's "runner exit before job
3407        // acceptance", process dead, elapsed 121s against a 120s startup window
3408        // -- which the rejected fix relabels `RegistrationTimedOut`. So the
3409        // objection is not only that a synthetic sweep dislikes the change; an
3410        // ordinary early crash, observed after a restart longer than the startup
3411        // window, gets reported as a runner that is still running. This test
3412        // remains the argument's *general* form, because it holds the claim at
3413        // every state, every observation and both sides of every deadline rather
3414        // than at one scenario.
3415        let timeouts = RecoveryTimeouts::new(
3416            Elapsed::seconds(60),
3417            Elapsed::seconds(120),
3418            Elapsed::seconds(300),
3419        );
3420        let entered_at = 1_000i64;
3421        // Both sides of each deadline, the deadlines themselves, and one
3422        // instant far past all three.
3423        let offsets = [0, 59, 60, 61, 119, 120, 121, 299, 300, 301, 100_000];
3424        let observations = [
3425            GithubRunnerObservation::NotRegistered,
3426            GithubRunnerObservation::Registered { busy: false },
3427            GithubRunnerObservation::Registered { busy: true },
3428            GithubRunnerObservation::Unreachable,
3429        ];
3430
3431        // Set by the third arm below, and asserted `false` after the sweep. It
3432        // is what keeps that arm from being a vacuous assertion nobody notices
3433        // has stopped meaning anything: today the arm is unreached, and this
3434        // says so out loud rather than leaving it to be assumed.
3435        let mut agent_termination_derived_here = false;
3436
3437        for state in AttemptState::ALL {
3438            for github in observations {
3439                for process_alive in [true, false] {
3440                    for offset in offsets {
3441                        let clock = StubClock::at(entered_at + offset);
3442                        let observation = RecoveryObservation {
3443                            process_alive,
3444                            github,
3445                        };
3446                        let decision = recovery_decision(
3447                            &attempt_in(state, entered_at),
3448                            observation,
3449                            timeouts,
3450                            &clock,
3451                        );
3452                        let reason = match &decision {
3453                            RecoveryDecision::Conclude(AttemptOutcome::Failed { reason })
3454                            | RecoveryDecision::Terminate(AttemptOutcome::Failed { reason }) => {
3455                                Some(reason)
3456                            }
3457                            _ => None,
3458                        };
3459                        match reason {
3460                            Some(FailureReason::RegistrationTimedOut) => assert!(
3461                                process_alive,
3462                                "{state} at +{offset}s with {github:?} and a process the \
3463                                 observation says is gone was reported as still running"
3464                            ),
3465                            Some(FailureReason::ProcessExitedUnexpectedly) => assert!(
3466                                !process_alive,
3467                                "{state} at +{offset}s with {github:?} reported an \
3468                                 unexpected exit for a process that is alive"
3469                            ),
3470                            // Nothing here derives this one today -- it is
3471                            // `e3`'s, recorded after reading its own journalled
3472                            // terminate-intent back, which is why the flag above
3473                            // records that the arm was reached at all. It is not
3474                            // idle cover: once the mark is journalled somewhere
3475                            // `recovery_decision` can read it, deriving the
3476                            // reason here becomes legitimate, and this is what
3477                            // stops that derivation landing beside a process the
3478                            // observation reports as still running.
3479                            Some(FailureReason::TerminatedAfterRegistrationTimeout) => {
3480                                agent_termination_derived_here = true;
3481                                assert!(
3482                                    !process_alive,
3483                                    "{state} at +{offset}s with {github:?} said the agent had \
3484                                     stopped a process the observation reports as alive"
3485                                );
3486                            }
3487                            _ => {}
3488                        }
3489                    }
3490                }
3491            }
3492        }
3493
3494        // The division of labour, asserted rather than described. The reason
3495        // that names an agent-initiated stop is not derivable from an
3496        // observation -- a process this agent killed and one that crashed on its
3497        // own look identical to `recovery_decision` -- so it must not appear
3498        // anywhere in the sweep above. If it ever does, the fact that separates
3499        // the two cases has become an input, and `RecoveryDecision::Terminate`'s
3500        // deferred obligation is either discharged or wrong; either way somebody
3501        // should be reading that doc rather than passing this test by accident.
3502        assert!(
3503            !agent_termination_derived_here,
3504            "`recovery_decision` derived `TerminatedAfterRegistrationTimeout`, \
3505             which is `e3`'s to record from journalled terminate-intent and not \
3506             this function's to infer from an observation that cannot know"
3507        );
3508    }
3509
3510    #[test]
3511    fn a_terminated_runner_is_never_described_as_running() {
3512        // The `Display` half of the same argument. `e3` concludes the window in
3513        // `RecoveryDecision::Terminate`'s doc with this reason, on a pass where
3514        // the process is already dead, so its rendering may not claim otherwise.
3515        // Reusing `RegistrationTimedOut` there -- the closure this file used to
3516        // promise -- would have printed "the runner process is running but did
3517        // not register" about a process the operator can see is gone: the same
3518        // false liveness claim option A is rejected for, one pass later.
3519        let terminated = FailureReason::TerminatedAfterRegistrationTimeout.to_string();
3520        assert!(
3521            !terminated.contains("running"),
3522            "this reason is only ever recorded about a process the agent has \
3523             already stopped: {terminated}"
3524        );
3525        assert!(
3526            !terminated.contains("exited unexpectedly"),
3527            "and nothing exited unexpectedly either -- the agent stopped it on \
3528             purpose: {terminated}"
3529        );
3530        assert!(
3531            terminated.contains("stopped") && terminated.contains("register"),
3532            "it has to say both what happened to the process and why, or it \
3533             sends an operator to a crash investigation: {terminated}"
3534        );
3535
3536        // Distinct from both neighbours, in the variant and in the string. A
3537        // reason that renders identically to another is a reason `g2` cannot
3538        // use to send an operator anywhere different.
3539        assert_ne!(
3540            terminated,
3541            FailureReason::RegistrationTimedOut.to_string(),
3542            "the live and the stopped case must read differently"
3543        );
3544        assert_ne!(
3545            terminated,
3546            FailureReason::ProcessExitedUnexpectedly.to_string()
3547        );
3548        assert!(
3549            !matches!(
3550                FailureReason::TerminatedAfterRegistrationTimeout,
3551                FailureReason::Other(_)
3552            ),
3553            "a known, named condition must not travel through the escape hatch"
3554        );
3555    }
3556
3557    #[test]
3558    fn the_two_starting_failures_read_differently_to_an_operator() {
3559        // `g2` renders `FailureReason` directly, and these two send an operator
3560        // in different directions: one to a crash investigation, the other to a
3561        // networking or configuration fix. Reaching for `Other` here instead of
3562        // a named variant would have put a free-form string on the same path.
3563        let exited = FailureReason::ProcessExitedUnexpectedly.to_string();
3564        let timed_out = FailureReason::RegistrationTimedOut.to_string();
3565        assert_ne!(exited, timed_out);
3566        assert!(
3567            timed_out.contains("running") && timed_out.contains("register"),
3568            "the message must say the process is up and unregistered: {timed_out}"
3569        );
3570        assert!(
3571            !timed_out.contains("exited"),
3572            "the whole point is that nothing exited: {timed_out}"
3573        );
3574        assert!(
3575            !matches!(FailureReason::RegistrationTimedOut, FailureReason::Other(_)),
3576            "a known, named condition must not travel through the escape hatch"
3577        );
3578    }
3579
3580    #[test]
3581    fn a_dead_idle_runner_still_registered_at_github_is_orphaned() {
3582        let clock = StubClock::at(1_000_000);
3583        assert_eq!(
3584            recovery_decision(
3585                &attempt_in(AttemptState::Idle, 0),
3586                RecoveryObservation {
3587                    process_alive: false,
3588                    github: GithubRunnerObservation::Registered { busy: false },
3589                },
3590                RecoveryTimeouts::provisional(),
3591                &clock,
3592            ),
3593            RecoveryDecision::Conclude(AttemptOutcome::Orphaned),
3594            "the remote registration outlived our process and needs removing"
3595        );
3596    }
3597}