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}