Skip to main content

aion_server/worker/supervisor/
fleet.rs

1//! The managed-worker supervisor: desired state in, running processes out.
2//!
3//! Desired state is the DURABLE record (`WorkerDeployment.desired`); actual
4//! state is a live [`InstanceHandle`]. This type is the only thing that reads
5//! one and writes the other, and every answer it gives is a join of the two —
6//! never one standing in for the other.
7//!
8//! Supervision is COMMISSIONED, not defaulted. A server whose operator has not
9//! written a `[worker_supervision]` policy supervises nothing and says so with
10//! the remedy attached; it does not pick a backoff on the operator's behalf
11//! (ADR-001).
12
13use std::collections::BTreeMap;
14use std::sync::{Arc, Mutex, OnceLock};
15
16use aion_core::ClusterEvent;
17use aion_store::{
18    DesiredState, WorkerArtifactRef, WorkerDeployment, WorkerDeploymentListing,
19    WorkerDeploymentStore,
20};
21
22use crate::cluster_publisher::ClusterEventPublisher;
23use crate::worker::auto_provision::AutoWorkerOutcome;
24
25use super::error::SupervisionError;
26use super::executable::ManagedExecutable;
27use super::instance::{InstanceConfig, InstanceHandle, InstanceSnapshot};
28use super::policy::{SupervisionPolicy, UNCOMMISSIONED_REMEDY};
29use super::status::{ManagedWorkerReport, ManagedWorkerState, ManagedWorkerStatus};
30
31/// What an operator commissioned: how to supervise, and what to launch.
32///
33/// Both halves arrive together and neither can be swapped afterwards. They
34/// belong to the same decision — a restart discipline is meaningless without
35/// knowing what is being restarted — and holding them in one write-once cell
36/// is what makes "commissioned" a single, checkable fact.
37#[derive(Clone, Debug)]
38struct Commission {
39    policy: SupervisionPolicy,
40    executable: ManagedExecutable,
41}
42
43/// How far a convergence may go when a live instance already exists.
44///
45/// The distinction is the staleness law: a supervised worker replays its
46/// record's argv VERBATIM, so a record whose argv was rewritten underneath a
47/// running instance is being served by a launch nobody deployed. Only the
48/// caller knows which of the two happened, so only the caller may choose.
49#[derive(Clone, Copy, Debug, Eq, PartialEq)]
50pub enum Convergence {
51    /// Leave an already-live instance running: it is serving this record's argv.
52    Idempotent,
53    /// Replace any live instance, because the record's argv was rewritten.
54    Replacing,
55}
56
57/// What [`WorkerSupervisor::shutdown`] observed, by name on both sides.
58#[derive(Debug, Default)]
59pub struct FleetShutdownReport {
60    /// Deployments proven stopped: each stop returned only after a
61    /// signal-zero probe found the worker's process group empty.
62    pub stopped: Vec<String>,
63    /// One entry per deployment that could NOT be proven stopped, carrying
64    /// the observation that contradicted it.
65    pub failures: Vec<SupervisionError>,
66}
67
68/// Server-owned supervision of the managed worker fleet.
69pub struct WorkerSupervisor {
70    store: Arc<dyn WorkerDeploymentStore>,
71    commission: OnceLock<Commission>,
72    instances: Mutex<BTreeMap<String, InstanceHandle>>,
73    publisher: ClusterEventPublisher,
74    /// The LATEST auto-provision decision per task queue, so the status
75    /// surface can say why a queue has no worker long after the deploy or the
76    /// boot that decided it. Keyed by queue and therefore bounded by what has
77    /// been deployed, never by how often.
78    auto_provision: Mutex<BTreeMap<String, AutoWorkerOutcome>>,
79    /// One async gate per deployment NAME, so every lifecycle verb's
80    /// read-record → tear-down → spawn sequence is a single critical section.
81    ///
82    /// The instances map alone is not enough. It makes two workers for one name
83    /// impossible, but it is released between the durable read and the spawn,
84    /// and two interleaved converges of one name could therefore leave the
85    /// EARLIER argv running: read(argv1) … read(argv2), replace→spawn(argv2),
86    /// replace→spawn(argv1). Nothing would notice — `reconcile` leaves live
87    /// instances alone — so the superseded launch survives until the next
88    /// restart, which is exactly the staleness `Convergence::Replacing` exists
89    /// to prevent.
90    ///
91    /// Keyed by name and therefore bounded by the deployment count. Entries
92    /// are never removed: a `tokio::sync::Mutex` is a few words, and reclaiming
93    /// them would need a second lock ordering to get wrong.
94    gates: Mutex<BTreeMap<String, Arc<tokio::sync::Mutex<()>>>>,
95}
96
97impl WorkerSupervisor {
98    /// Build an UNCOMMISSIONED supervisor over the durable deployment store,
99    /// publishing desired-state changes onto the deployment-global cluster
100    /// channel.
101    ///
102    /// The publisher lives HERE — not in the transports — so every desired-state
103    /// write the supervisor makes ([`Self::start`], [`Self::stop`], and
104    /// [`Self::restart`] through them) reaches the console's live feed
105    /// identically whichever transport asked, exactly as the
106    /// worker-deployment desired-state endpoint publishes its own writes.
107    ///
108    /// Nothing is supervised until [`Self::commission`] installs an operator
109    /// policy: construction is not commissioning, so a server that boots
110    /// without the config section never spawns anything.
111    #[must_use]
112    pub fn new(store: Arc<dyn WorkerDeploymentStore>, publisher: ClusterEventPublisher) -> Self {
113        Self {
114            store,
115            commission: OnceLock::new(),
116            instances: Mutex::new(BTreeMap::new()),
117            publisher,
118            auto_provision: Mutex::new(BTreeMap::new()),
119            gates: Mutex::new(BTreeMap::new()),
120        }
121    }
122
123    /// The per-name gate, created on first use.
124    ///
125    /// A poisoned gate map is a typed error rather than a silent free-for-all:
126    /// proceeding without the gate is exactly the interleaving it exists to
127    /// prevent, and doing that quietly would make the race intermittent instead
128    /// of impossible.
129    fn gate(&self, name: &str) -> Result<Arc<tokio::sync::Mutex<()>>, SupervisionError> {
130        let mut gates = self
131            .gates
132            .lock()
133            .map_err(|poison| SupervisionError::StatePoisoned {
134                detail: poison.to_string(),
135            })?;
136        Ok(Arc::clone(
137            gates
138                .entry(name.to_owned())
139                .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))),
140        ))
141    }
142
143    /// Record what auto-provision decided about each task queue, replacing any
144    /// earlier decision for the same queue.
145    ///
146    /// A failure to take the lock is logged rather than propagated: losing the
147    /// REPORT of a provisioning decision must not turn a successful deploy into
148    /// a refusal, and the decision itself has already been logged and returned
149    /// to its caller by the time this is called.
150    pub fn record_auto_provision(&self, outcomes: &[AutoWorkerOutcome]) {
151        match self.auto_provision.lock() {
152            Ok(mut log) => {
153                for outcome in outcomes {
154                    drop(log.insert(outcome.task_queue.clone(), outcome.clone()));
155                }
156            }
157            Err(poison) => tracing::error!(
158                detail = %poison,
159                "the auto-provision log is poisoned; this server's managed-worker report will \
160                 not say why a queue has no built-in agent worker"
161            ),
162        }
163    }
164
165    /// The latest auto-provision decision per queue, ordered by queue.
166    ///
167    /// A poisoned log reports EMPTY rather than refusing the whole status read:
168    /// the fleet join is the operator's primary answer and must keep arriving.
169    /// The poison itself was already logged where it was found.
170    fn auto_provision_log(&self) -> Vec<AutoWorkerOutcome> {
171        self.auto_provision
172            .lock()
173            .map(|log| log.values().cloned().collect())
174            .unwrap_or_default()
175    }
176
177    /// Install the operator's supervision policy and the executable a
178    /// `builtin` deployment means on this server.
179    ///
180    /// Returns false when a commission was already installed, in which case
181    /// the existing one stands: the restart discipline of a running fleet is
182    /// not something a later caller gets to swap out underneath it.
183    pub fn commission(&self, policy: SupervisionPolicy, executable: ManagedExecutable) -> bool {
184        self.commission
185            .set(Commission { policy, executable })
186            .is_ok()
187    }
188
189    /// The installed policy, if this server has one.
190    #[must_use]
191    pub fn policy(&self) -> Option<SupervisionPolicy> {
192        self.commission.get().map(|commission| commission.policy)
193    }
194
195    /// Whether an operator commission is installed.
196    #[must_use]
197    pub fn is_commissioned(&self) -> bool {
198        self.commission.get().is_some()
199    }
200
201    fn require_commission(&self) -> Result<&Commission, SupervisionError> {
202        self.commission
203            .get()
204            .ok_or(SupervisionError::NotCommissioned)
205    }
206
207    fn instances(
208        &self,
209    ) -> Result<std::sync::MutexGuard<'_, BTreeMap<String, InstanceHandle>>, SupervisionError> {
210        self.instances
211            .lock()
212            .map_err(|poison| SupervisionError::StatePoisoned {
213                detail: poison.to_string(),
214            })
215    }
216
217    async fn record(&self, name: &str) -> Result<WorkerDeployment, SupervisionError> {
218        self.store
219            .get_worker_deployment(name)
220            .await
221            .map_err(|source| SupervisionError::Store { source })?
222            .ok_or_else(|| SupervisionError::UnknownDeployment {
223                name: name.to_owned(),
224            })
225    }
226
227    async fn listing(&self) -> Result<WorkerDeploymentListing, SupervisionError> {
228        self.store
229            .list_worker_deployments()
230            .await
231            .map_err(|source| SupervisionError::Store { source })
232    }
233
234    /// Start (or adopt) supervision of one deployment, recording the intent
235    /// durably first. A durable desired-state flip is published onto the
236    /// cluster channel, so the console's live feed sees it whichever transport
237    /// asked.
238    ///
239    /// Idempotent: a deployment already being supervised is reported as it is,
240    /// without a second process (and, already desiring `Running`, without a
241    /// second event).
242    ///
243    /// # Errors
244    ///
245    /// Returns [`SupervisionError::NotCommissioned`] when no policy is
246    /// installed — carrying the remedy — [`SupervisionError::UnknownDeployment`]
247    /// for a name with no durable record, and [`SupervisionError::Store`] when
248    /// the record cannot be read or the intent cannot be written.
249    pub async fn start(&self, name: &str) -> Result<ManagedWorkerStatus, SupervisionError> {
250        let gate = self.gate(name)?;
251        let held = gate.lock().await;
252        let started = self.start_gated(name).await;
253        drop(held);
254        started
255    }
256
257    /// [`Self::start`] with the per-name gate ALREADY held by the caller.
258    async fn start_gated(&self, name: &str) -> Result<ManagedWorkerStatus, SupervisionError> {
259        let commission = self.require_commission()?.clone();
260        let record = self.record(name).await?;
261        let record = if record.desired == DesiredState::Running {
262            record
263        } else {
264            let record = self
265                .store
266                .set_desired_state(name, DesiredState::Running)
267                .await
268                .map_err(|source| SupervisionError::Store { source })?
269                .ok_or_else(|| SupervisionError::UnknownDeployment {
270                    name: name.to_owned(),
271                })?;
272            self.publish_desired_state(&record);
273            record
274        };
275
276        let snapshot = self.ensure_instance(&record, &commission)?;
277        Ok(status_of(&record, snapshot, self.is_commissioned()))
278    }
279
280    /// Bring ONE deployment's live state to its already-durable desired state,
281    /// writing no durable intent and publishing no desired-state event.
282    ///
283    /// This is what a transport calls immediately after it has written the
284    /// record itself. A durable `desired = running` is an INSTRUCTION, and the
285    /// node that accepted it acts on it now rather than at the next boot
286    /// reconcile — but the write and its event already happened at the
287    /// transport, so re-writing them here would double-publish a change that
288    /// occurred once. [`Self::start`] and [`Self::stop`] remain the verbs for a
289    /// caller that is asking for the intent to CHANGE.
290    ///
291    /// `mode` decides what an already-live instance means: [`Convergence::Idempotent`]
292    /// leaves it running, [`Convergence::Replacing`] tears it down first because
293    /// the record it is replaying was rewritten under it.
294    ///
295    /// # Errors
296    ///
297    /// Returns [`SupervisionError::UnknownDeployment`] for a name with no
298    /// durable record, [`SupervisionError::Store`] when the record cannot be
299    /// read, [`SupervisionError::NotCommissioned`] when the record wants to run
300    /// and no policy is installed, and [`SupervisionError::StopIncomplete`] when
301    /// a replaced or stopped instance cannot be proven gone.
302    pub async fn converge(
303        &self,
304        name: &str,
305        mode: Convergence,
306    ) -> Result<ManagedWorkerStatus, SupervisionError> {
307        let gate = self.gate(name)?;
308        let held = gate.lock().await;
309        let converged = self.converge_gated(name, mode).await;
310        drop(held);
311        converged
312    }
313
314    /// [`Self::converge`] with the per-name gate ALREADY held by the caller.
315    ///
316    /// Everything from the durable read to the spawn happens inside that gate:
317    /// two concurrent converges of one name are serialized, so the launch left
318    /// running is always the one the LAST durable write named.
319    async fn converge_gated(
320        &self,
321        name: &str,
322        mode: Convergence,
323    ) -> Result<ManagedWorkerStatus, SupervisionError> {
324        let record = self.record(name).await?;
325        if record.desired == DesiredState::Stopped {
326            self.tear_down(name).await?;
327            return Ok(status_of(&record, None, self.is_commissioned()));
328        }
329        // Commission is required only to RUN something. Checking it after the
330        // stopped arm above is deliberate: an uncommissioned server must still
331        // be able to converge a deployment DOWN.
332        let commission = self.require_commission()?.clone();
333        if mode == Convergence::Replacing {
334            self.tear_down(name).await?;
335        }
336        let snapshot = self.ensure_instance(&record, &commission)?;
337        Ok(status_of(&record, snapshot, self.is_commissioned()))
338    }
339
340    /// Drop one deployment out of supervision entirely, proving any live
341    /// instance stopped first, without touching durable state.
342    ///
343    /// For the transport that has just DELETED the record: a supervised process
344    /// whose record no longer exists is an orphan nothing can report on, stop,
345    /// or restart — `report()` joins the durable listing, so it would vanish
346    /// from every surface while still holding its port and its process group.
347    ///
348    /// # Errors
349    ///
350    /// Returns [`SupervisionError::StopIncomplete`] when the instance's process
351    /// group cannot be proven empty, [`SupervisionError::TaskLost`] when its
352    /// supervision task ended abnormally, and
353    /// [`SupervisionError::StatePoisoned`] when the instance map was poisoned.
354    pub async fn forget(&self, name: &str) -> Result<(), SupervisionError> {
355        let gate = self.gate(name)?;
356        let held = gate.lock().await;
357        let forgotten = self.tear_down(name).await;
358        drop(held);
359        forgotten
360    }
361
362    /// Remove any live instance for `name` and prove its process group empty.
363    /// Absent instances are a no-op, which is what makes every caller idempotent.
364    ///
365    /// The pid and process group are read BEFORE the stop, because the stop
366    /// consumes the handle: a failure to prove the group empty is the one
367    /// moment those two numbers matter, and they are gone by the time the
368    /// failure exists unless they are captured first.
369    async fn tear_down(&self, name: &str) -> Result<(), SupervisionError> {
370        let handle = self.instances()?.remove(name);
371        let Some(handle) = handle else {
372            return Ok(());
373        };
374        let identity = handle.snapshot().ok().map(|snapshot| ProcessIdentity {
375            pid: snapshot.pid,
376            process_group: snapshot.process_group,
377        });
378        handle
379            .stop(name)
380            .await
381            .map_err(|error| attach_process_identity(error, identity))
382    }
383
384    /// Ensure a live instance exists for `record`, returning its snapshot.
385    ///
386    /// The lock is held across the liveness test and the insert so two callers
387    /// cannot both observe "not live" and spawn two workers for one deployment.
388    fn ensure_instance(
389        &self,
390        record: &WorkerDeployment,
391        commission: &Commission,
392    ) -> Result<Option<InstanceSnapshot>, SupervisionError> {
393        let mut instances = self.instances()?;
394        let live = instances
395            .get(&record.name)
396            .is_some_and(|handle| !handle.is_finished());
397        if !live {
398            let handle = InstanceHandle::start(self.instance_config(record, commission));
399            drop(instances.insert(record.name.clone(), handle));
400        }
401        instances
402            .get(&record.name)
403            .map(InstanceHandle::snapshot)
404            .transpose()
405    }
406
407    /// Stop one deployment and record the intent durably. The durable
408    /// desired-state write is published onto the cluster channel, so the
409    /// console's live feed sees it whichever transport asked.
410    ///
411    /// The returned status is written only once the process group has been
412    /// probed empty; a group that survives the termination ladder produces
413    /// [`SupervisionError::StopIncomplete`] instead, so a caller can never read
414    /// "stopped" off bookkeeping alone.
415    ///
416    /// # Errors
417    ///
418    /// Returns [`SupervisionError::UnknownDeployment`] for an absent record,
419    /// [`SupervisionError::Store`] when the intent cannot be written,
420    /// [`SupervisionError::StopIncomplete`] when the group cannot be proven
421    /// empty, and [`SupervisionError::TaskLost`] when the supervision task
422    /// ended abnormally.
423    pub async fn stop(&self, name: &str) -> Result<ManagedWorkerStatus, SupervisionError> {
424        let gate = self.gate(name)?;
425        let held = gate.lock().await;
426        let stopped = self.stop_gated(name).await;
427        drop(held);
428        stopped
429    }
430
431    /// [`Self::stop`] with the per-name gate ALREADY held by the caller.
432    async fn stop_gated(&self, name: &str) -> Result<ManagedWorkerStatus, SupervisionError> {
433        let record = self
434            .store
435            .set_desired_state(name, DesiredState::Stopped)
436            .await
437            .map_err(|source| SupervisionError::Store { source })?
438            .ok_or_else(|| SupervisionError::UnknownDeployment {
439                name: name.to_owned(),
440            })?;
441        self.publish_desired_state(&record);
442        self.tear_down(name).await?;
443        Ok(status_of(&record, None, self.is_commissioned()))
444    }
445
446    /// Stop and start one deployment, leaving desired state at `Running`.
447    ///
448    /// Publishes exactly what it durably writes: a restart of an
449    /// already-`Running` deployment changes no desired state and emits no
450    /// desired-state event; one that flips it inherits [`Self::start`]'s.
451    ///
452    /// # Errors
453    ///
454    /// Returns the same failures as [`Self::stop`] and [`Self::start`].
455    pub async fn restart(&self, name: &str) -> Result<ManagedWorkerStatus, SupervisionError> {
456        // ONE gate for the whole stop-then-start, taken here and held across
457        // both halves: a restart that released it between them would let
458        // another caller's converge spawn into the gap and then be torn down
459        // by this restart's own start.
460        let gate = self.gate(name)?;
461        let held = gate.lock().await;
462        let restarted = self.restart_gated(name).await;
463        drop(held);
464        restarted
465    }
466
467    /// [`Self::restart`] with the per-name gate ALREADY held by the caller.
468    async fn restart_gated(&self, name: &str) -> Result<ManagedWorkerStatus, SupervisionError> {
469        self.require_commission()?;
470        self.record(name).await?;
471        self.tear_down(name).await?;
472        self.start_gated(name).await
473    }
474
475    /// Bring the fleet to its durable desired state.
476    ///
477    /// Called at boot and safe to call again: deployments already supervised
478    /// are left alone. Returns the number of deployments now supervised.
479    ///
480    /// Each record is brought up under its OWN per-name gate, and its record is
481    /// re-read inside it: a reconcile running beside a concurrent PUT must not
482    /// spawn the argv it happened to list before that write landed.
483    ///
484    /// # Errors
485    ///
486    /// Returns [`SupervisionError::NotCommissioned`] when no policy is
487    /// installed, and [`SupervisionError::Store`] when the durable listing
488    /// cannot be read.
489    pub async fn reconcile(&self) -> Result<usize, SupervisionError> {
490        drop(self.require_commission()?.clone());
491        let listing = self.listing().await?;
492        let mut supervised = 0_usize;
493        for record in listing
494            .deployments
495            .iter()
496            .filter(|record| record.desired == DesiredState::Running)
497        {
498            match self.converge(&record.name, Convergence::Idempotent).await {
499                Ok(_) => supervised = supervised.saturating_add(1),
500                // One record that cannot be converged must not abandon the
501                // rest of the fleet: a boot that stops at the first failure
502                // leaves every later deployment unsupervised and silent.
503                Err(error) => tracing::error!(
504                    worker = record.name.as_str(),
505                    %error,
506                    "worker deployment could not be converged; it is not being supervised"
507                ),
508            }
509        }
510        for poisoned in &listing.undecodable {
511            tracing::error!(
512                worker = poisoned.name.as_str(),
513                error = poisoned.error.as_str(),
514                "worker deployment record could not be decoded; it is not being supervised"
515            );
516        }
517        Ok(supervised)
518    }
519
520    /// Join durable intent with live supervision for every deployment.
521    ///
522    /// # Errors
523    ///
524    /// Returns [`SupervisionError::Store`] when the durable listing cannot be
525    /// read, and [`SupervisionError::StatePoisoned`] when a status cell was
526    /// poisoned by a panicking holder.
527    pub async fn report(&self) -> Result<ManagedWorkerReport, SupervisionError> {
528        let listing = self.listing().await?;
529        let commissioned = self.is_commissioned();
530        let mut workers = Vec::with_capacity(listing.deployments.len());
531        {
532            let instances = self.instances()?;
533            for record in &listing.deployments {
534                let snapshot = instances
535                    .get(&record.name)
536                    .map(InstanceHandle::snapshot)
537                    .transpose()?;
538                workers.push(status_of(record, snapshot, commissioned));
539            }
540        }
541        Ok(ManagedWorkerReport {
542            commissioned,
543            remedy: (!commissioned).then(|| UNCOMMISSIONED_REMEDY.to_owned()),
544            workers,
545            undecodable: listing
546                .undecodable
547                .iter()
548                .map(|poisoned| poisoned.name.clone())
549                .collect(),
550            auto_provision: self.auto_provision_log(),
551        })
552    }
553
554    /// Stop every supervised instance, for server shutdown.
555    ///
556    /// Durable desired state is deliberately NOT changed: a server going down
557    /// is not an operator asking for the fleet to stay down, and the next boot
558    /// reconciles it back up. The report names both sides: every instance
559    /// proven stopped (its process group observed empty) and one failure per
560    /// instance that could not be — an empty failure list is the proof that
561    /// no worker was orphaned, and the stopped names let the shutdown
562    /// outcome record say WHICH workers went down rather than a count.
563    pub async fn shutdown(&self) -> FleetShutdownReport {
564        let handles = match self.instances() {
565            Ok(mut instances) => std::mem::take(&mut *instances),
566            Err(error) => {
567                return FleetShutdownReport {
568                    stopped: Vec::new(),
569                    failures: vec![error],
570                };
571            }
572        };
573        let mut report = FleetShutdownReport {
574            stopped: Vec::new(),
575            failures: Vec::new(),
576        };
577        for (name, handle) in handles {
578            match handle.stop(&name).await {
579                Ok(()) => report.stopped.push(name),
580                Err(error) => report.failures.push(error),
581            }
582        }
583        report
584    }
585
586    /// Publish one durable desired-state write onto the cluster channel — the
587    /// SAME event the worker-deployment desired-state endpoint publishes for
588    /// its writes, so the live feed cannot tell the transports apart.
589    ///
590    /// Called exactly where a write happened, never speculatively: an event
591    /// here is proof of a durable change. The emitted event's return value is
592    /// dropped deliberately — a channel with no subscribers is the calm state,
593    /// not a failure.
594    fn publish_desired_state(&self, record: &WorkerDeployment) {
595        let name = record.name.clone();
596        let desired_state = record.desired;
597        drop(
598            self.publisher
599                .emit(|meta| ClusterEvent::WorkerDeploymentDesiredStateChanged {
600                    meta,
601                    name,
602                    desired_state,
603                }),
604        );
605    }
606
607    fn instance_config(
608        &self,
609        record: &WorkerDeployment,
610        commission: &Commission,
611    ) -> InstanceConfig {
612        let WorkerArtifactRef::Builtin { verb } = &record.artifact;
613        InstanceConfig {
614            name: record.name.clone(),
615            verb: verb.clone(),
616            executable: commission.executable.clone(),
617            policy: commission.policy,
618            store: Arc::clone(&self.store),
619        }
620    }
621}
622
623/// The two numbers an operator needs to find a process the server could not
624/// prove stopped, captured before the stop consumes the handle that holds them.
625struct ProcessIdentity {
626    pid: Option<u32>,
627    process_group: Option<i32>,
628}
629
630/// Name the surviving process group in a stop failure.
631///
632/// Only [`SupervisionError::StopIncomplete`] is rewritten, and only when the
633/// identity was actually read: every other failure is about something else, and
634/// an unknown pid is reported as unknown rather than as a number nobody
635/// measured.
636fn attach_process_identity(
637    error: SupervisionError,
638    identity: Option<ProcessIdentity>,
639) -> SupervisionError {
640    match (error, identity) {
641        (SupervisionError::StopIncomplete { name, detail }, Some(identity)) => {
642            let pid = identity
643                .pid
644                .map_or_else(|| String::from("unknown"), |pid| pid.to_string());
645            let group = identity
646                .process_group
647                .map_or_else(|| String::from("unknown"), |group| group.to_string());
648            SupervisionError::StopIncomplete {
649                name,
650                detail: format!("{detail} (last observed pid {pid}, process group {group})"),
651            }
652        }
653        (error, _) => error,
654    }
655}
656
657/// Join one durable record with one live snapshot.
658fn status_of(
659    record: &WorkerDeployment,
660    snapshot: Option<InstanceSnapshot>,
661    commissioned: bool,
662) -> ManagedWorkerStatus {
663    // Three different absences, three different answers. An instance that
664    // exists but has not spoken yet is STARTING; a server that could not have
665    // supervised is UNSUPERVISED; only genuinely nothing-to-run is STOPPED.
666    // Collapsing any of them into another is how a status surface comes to
667    // report a terminal state about work that is under way.
668    let supervised = snapshot.is_some();
669    let snapshot = snapshot.unwrap_or_default();
670    let state = snapshot.state.unwrap_or({
671        if supervised {
672            ManagedWorkerState::Starting
673        } else if record.desired == DesiredState::Running && !commissioned {
674            ManagedWorkerState::Uncommissioned
675        } else {
676            ManagedWorkerState::Stopped
677        }
678    });
679    ManagedWorkerStatus {
680        name: record.name.clone(),
681        task_queue: record.task_queue.clone(),
682        desired: record.desired,
683        state,
684        pid: snapshot.pid,
685        process_group: snapshot.process_group,
686        restarts: snapshot.restarts,
687        last_exit: snapshot.last_exit,
688        last_error: snapshot.last_error,
689        deployed_binary: record.binary.clone(),
690        spawn_binary: snapshot.spawn_binary,
691    }
692}