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