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