Skip to main content

aion_server/worker/auto_provision/
outcome.rs

1//! What auto-provision did about one task queue, and why.
2//!
3//! Every arm is reported, including the ones where nothing was minted. A queue
4//! that quietly got no worker is indistinguishable from a queue nobody asked
5//! about, and the difference is the whole operator question: "I deployed a
6//! document with a `harness` section and nothing is serving it."
7
8use serde::{Deserialize, Serialize};
9
10/// What was decided about one task queue.
11#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
12#[serde(rename_all = "kebab-case")]
13pub enum AutoWorkerDecision {
14    /// No record served this queue, so one was minted and converged.
15    Minted,
16    /// The queue's own auto record existed and named a DIFFERENT document, so
17    /// it was replaced and its worker restarted onto the new one.
18    Reminted,
19    /// The queue's own auto record already names this exact document. Nothing
20    /// was written; the running worker was left alone and merely converged.
21    Unchanged,
22    /// The record was written durably, and this server could NOT start it. The
23    /// record stands and the next boot's reconcile honours it; `detail` carries
24    /// the supervision failure and its remedy.
25    ///
26    /// Distinct from every arm above because those all claim a running worker,
27    /// and claiming one that does not exist is the exact defect this whole lane
28    /// exists to end — one layer up.
29    RecordedNotRunning,
30    /// The queue's own auto record was deliberately STOPPED by an operator, and
31    /// their intent was preserved rather than reversed.
32    OperatorStopped,
33    /// The document no longer declares this queue, so the record this server
34    /// minted for it was withdrawn and its worker stopped.
35    Retired,
36    /// A record the OPERATOR authored already serves this queue. Their record
37    /// wins and nothing here touched or duplicated it.
38    OperatorRecord,
39    /// This server binds no liminal worker listener, so a minted record would
40    /// dial nothing. Refused, with the `[outbox]` remedy in `detail`.
41    DarkOutbox,
42    /// The document's harness names an agent THIS host cannot launch — an
43    /// absent or non-executable agent binary, or a literal working directory
44    /// that does not exist here. Refused with the missing path named; the
45    /// deploy itself landed. Measured 2026-08-26 on the estate: a fleet
46    /// document deployed from a Linux box minted workers on the Mac server
47    /// whose harness named `/home/aion/...` paths, and every dispatch routed
48    /// to them burned an attempt on a spawn that could only fail.
49    UnrunnableHarness,
50    /// Auto-provision could not be carried out. `detail` says what refused.
51    Failed,
52}
53
54impl AutoWorkerDecision {
55    /// Stable token for logs and metrics.
56    #[must_use]
57    pub const fn token(self) -> &'static str {
58        match self {
59            Self::Minted => "minted",
60            Self::Reminted => "reminted",
61            Self::Unchanged => "unchanged",
62            Self::RecordedNotRunning => "recorded-not-running",
63            Self::OperatorStopped => "operator-stopped",
64            Self::Retired => "retired",
65            Self::OperatorRecord => "operator-record",
66            Self::DarkOutbox => "dark-outbox",
67            Self::UnrunnableHarness => "unrunnable-harness",
68            Self::Failed => "failed",
69        }
70    }
71
72    /// Whether this outcome left the queue WITHOUT the built-in agent worker
73    /// the document asked for by declaring a `harness` section.
74    ///
75    /// Three arms are deliberately NOT refusals, because in each of them a
76    /// person already decided: [`Self::OperatorRecord`] (their record serves
77    /// the queue), [`Self::OperatorStopped`] (they stopped it on purpose), and
78    /// [`Self::Retired`] (the document stopped declaring the queue).
79    #[must_use]
80    pub const fn is_refusal(self) -> bool {
81        matches!(
82            self,
83            Self::DarkOutbox | Self::Failed | Self::RecordedNotRunning | Self::UnrunnableHarness
84        )
85    }
86
87    /// Whether this outcome CLAIMS a running worker.
88    ///
89    /// The claim is only ever made where a convergence returned success. It is
90    /// separated from the decision because a record can be written correctly
91    /// and still not run, and reporting those two as one thing is how a status
92    /// surface comes to say a worker started when no process exists.
93    #[must_use]
94    pub const fn claims_running(self) -> bool {
95        matches!(self, Self::Minted | Self::Reminted | Self::Unchanged)
96    }
97}
98
99/// One task queue's auto-provision outcome, as reported to the deploy caller,
100/// the boot log, and the managed-worker status surface.
101#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
102pub struct AutoWorkerOutcome {
103    /// The task queue this is about.
104    pub task_queue: String,
105    /// The workflow type whose document declared the queue.
106    ///
107    /// Carried so a document-level failure — one where no queue name could be
108    /// read out of the source at all — is still attributable to a document.
109    /// Without it two unparseable documents are one indistinguishable entry.
110    pub workflow_type: String,
111    /// What was decided.
112    pub decision: AutoWorkerDecision,
113    /// The deployment record's name, when there is one to name — the auto
114    /// record on the minted arms, the operator's own on [`AutoWorkerDecision::OperatorRecord`].
115    pub deployment: Option<String>,
116    /// The sentence an operator reads. ALWAYS present, on every arm: a
117    /// decision token with no account of itself is a decision nobody can act on.
118    pub detail: String,
119}
120
121impl AutoWorkerOutcome {
122    /// One outcome for a queue, with its account.
123    pub fn new(
124        task_queue: impl Into<String>,
125        workflow_type: impl Into<String>,
126        decision: AutoWorkerDecision,
127        deployment: Option<String>,
128        detail: impl Into<String>,
129    ) -> Self {
130        Self {
131            task_queue: task_queue.into(),
132            workflow_type: workflow_type.into(),
133            decision,
134            deployment,
135            detail: detail.into(),
136        }
137    }
138
139    /// A refusal that could not be attributed to any one queue — the document
140    /// itself did not parse, so no queue name was ever read out of it.
141    pub fn document_level(workflow_type: impl Into<String>, detail: impl Into<String>) -> Self {
142        Self::new(
143            String::new(),
144            workflow_type,
145            AutoWorkerDecision::Failed,
146            None,
147            detail,
148        )
149    }
150
151    /// The key this outcome is kept under in the supervisor's per-queue log.
152    ///
153    /// The task queue, because the operator's question is about a queue and one
154    /// queue has one auto record however many documents declare it. A
155    /// document-level failure has no queue, so it is keyed by the document
156    /// instead — otherwise two unparseable documents overwrite each other under
157    /// one blank name.
158    #[must_use]
159    pub fn log_key(&self) -> String {
160        if self.task_queue.is_empty() {
161            format!("document:{}", self.workflow_type)
162        } else {
163            self.task_queue.clone()
164        }
165    }
166}