aion_integrations/error.rs
1//! The harness-neutral error taxonomy for the integration seam.
2//!
3//! [`HarnessError`] is the single error type every [`crate::AgentHarness`] /
4//! [`crate::AgentSession`] method returns. It is **harness-neutral**: no variant names a
5//! concrete harness, and only the transport/protocol variants reference the notion of a wire
6//! at all (as generic descriptions, never a specific protocol type). An adapter maps its own
7//! failures onto these variants; callers above the adapter branch on the variant alone.
8
9/// How an adapter recognised a provider policy refusal.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum PolicyRefusalDetection {
12 /// A typed or structured discriminator identified the refusal.
13 Structured,
14 /// The adapter matched a documented, narrowly bounded provider message.
15 Textual,
16}
17
18/// The neutral error taxonomy for the harness-integration seam.
19///
20/// Every arm is harness-neutral. [`Self::CapabilityNotSupported`] is the first-class outcome an
21/// observability-only harness returns from [`crate::AgentSession::intervene`] for any command —
22/// it is a legitimate, gated rejection, not an internal failure.
23#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
24#[non_exhaustive]
25pub enum HarnessError {
26 /// The requested intervention primitive is not in the harness's advertised capability set.
27 ///
28 /// This is the first-class rejection an observability-only harness (empty capability set)
29 /// returns for *every* command, and the rejection any harness returns for a primitive it did
30 /// not advertise. It is a normal, expected outcome of capability gating — not a fault.
31 #[error("capability not supported: {primitive}")]
32 CapabilityNotSupported {
33 /// A neutral label naming the unsupported primitive (e.g. `"pause_resume"`).
34 primitive: String,
35 },
36 /// The command targets a stale or unknown activity attempt and is a no-op.
37 ///
38 /// A command addressed to a superseded attempt (a later attempt is now live) or to a session
39 /// that has already reached its terminal result is dropped without effect.
40 #[error("stale target: {detail}")]
41 StaleTarget {
42 /// Human-readable detail describing why the target is stale.
43 detail: String,
44 },
45 /// The spawn target is held by a live sibling process, so no run was started.
46 ///
47 /// A TRANSIENT refusal, and its own variant precisely because it must NOT classify like
48 /// [`Self::Configuration`]: nothing anybody WROTE is wrong. The declared working tree is
49 /// occupied by a live process — a surviving attempt from before a server death, or the
50 /// previous workflow's agent still winding down in a sequentially reused tree — and the
51 /// occupancy ends the moment the holder exits or dies, at which point the stale-marker
52 /// cleanup admits the next claimant. The guard's refusal stays absolute while the holder
53 /// lives; only its CLASSIFICATION is retryable. Field case: issue #33 — server-death
54 /// recovery re-dispatched builder legs whose original processes had survived, and the
55 /// terminal classification killed a two-hour fleet run over a condition that clears by
56 /// itself. The worker maps this variant to a RETRYABLE activity failure; how long to keep
57 /// waiting belongs to the retry policy.
58 #[error("spawn target occupied: {detail}")]
59 Occupied {
60 /// Human-readable detail naming the live holder (pid, workflow, activity).
61 detail: String,
62 },
63 /// The underlying transport failed (spawn/connect failure, broken pipe, EOF, I/O error).
64 ///
65 /// Neutral: it describes *that* the transport failed and carries the detail, never *which*
66 /// transport. An adapter maps its own I/O failures here.
67 #[error("transport error: {detail}")]
68 Transport {
69 /// Human-readable description of the transport failure.
70 detail: String,
71 },
72 /// A message was received that violates the wire protocol contract.
73 ///
74 /// Malformed framing, an undecodable envelope, a response that correlates to no outstanding
75 /// request, or a terminal result delivered on the wrong message kind. This signals a bug in
76 /// the peer or the adapter, distinct from an ordinary transport outage.
77 #[error("protocol error: {detail}")]
78 Protocol {
79 /// Human-readable description of the protocol violation.
80 detail: String,
81 },
82 /// The harness reported an application-level failure while running the agent.
83 ///
84 /// The agent ran but ended in failure (a non-success exit, an error result, a rejected run).
85 /// Distinct from [`Self::Transport`] (the channel broke) and [`Self::Protocol`] (a malformed
86 /// message): here the channel and framing were sound and the harness *reported* failure.
87 #[error("harness reported failure: {detail}")]
88 Harness {
89 /// Human-readable description of the reported failure.
90 detail: String,
91 },
92 /// The provider declined to execute the run under its safety policy.
93 ///
94 /// This is stochastic: the same content has succeeded on a later attempt, so
95 /// [`Self::is_deterministic`] returns `false`. It remains distinct from
96 /// [`Self::Harness`] because callers route on this class before considering an
97 /// ordinary same-provider retry.
98 #[error("provider policy refused the run: {detail}")]
99 PolicyRefused {
100 /// Human-readable adapter-owned detail preserving the provider's refusal.
101 detail: String,
102 /// Whether recognition used a structured discriminator or admitted prose.
103 detection: PolicyRefusalDetection,
104 },
105 /// The harness cannot be launched as CONFIGURED, so no run was started.
106 ///
107 /// A DETERMINISTIC refusal, and its own variant for the same reason
108 /// [`Self::Contract`] is: the channel never opened, so [`Self::Transport`] is wrong
109 /// and would tell an operator a story about a flaky pipe; no frame was exchanged, so
110 /// [`Self::Protocol`] is wrong; and nothing ran, so [`Self::Harness`] is wrong. What
111 /// is wrong is a value somebody WROTE — an environment pass-through entry that is a
112 /// `KEY=VALUE` pair instead of a name, a declaration that omits the variable the
113 /// program is looked up on. The next attempt reads the same document and meets the
114 /// same wall, so retrying spends a worker's attempt budget to learn nothing. The
115 /// worker maps this variant to a TERMINAL activity failure.
116 #[error("harness configuration refusal: {detail}")]
117 Configuration {
118 /// Human-readable description naming the setting and what is wrong with it.
119 detail: String,
120 },
121 /// The run completed, but its native outcome cannot satisfy the canonical
122 /// agent-outcome contract
123 /// (`AgentOutcome { text, final_message, stop_reason, session_id }`).
124 ///
125 /// A DETERMINISTIC refusal, and that is the whole reason it is its own variant: the channel
126 /// was sound ([`Self::Transport`] is wrong), the frames were well-formed ([`Self::Protocol`]
127 /// is wrong — and a protocol fault CAN be a transient peer flake, which this never is), and
128 /// the run did not fail ([`Self::Harness`] is wrong). The run's *configuration* produces an
129 /// outcome the seam excludes — e.g. a null final output where the contract's `text` demands
130 /// a String (a structured output is NOT excluded: it rides as its JSON text) — so re-running
131 /// it re-spends a whole agent run to hit the same wall. The worker
132 /// maps this variant to a TERMINAL activity failure; every other variant stays retryable.
133 #[error("agent-outcome contract refusal: {detail}")]
134 Contract {
135 /// Human-readable description naming the contract and what was found.
136 detail: String,
137 },
138}
139
140impl HarnessError {
141 /// Builds a [`Self::CapabilityNotSupported`] naming the unsupported primitive.
142 #[must_use]
143 pub fn capability_not_supported(primitive: impl Into<String>) -> Self {
144 Self::CapabilityNotSupported {
145 primitive: primitive.into(),
146 }
147 }
148
149 /// Builds a [`Self::StaleTarget`] with a detail message.
150 #[must_use]
151 pub fn stale_target(detail: impl Into<String>) -> Self {
152 Self::StaleTarget {
153 detail: detail.into(),
154 }
155 }
156
157 /// Builds a [`Self::Occupied`] with a detail message naming the live holder
158 /// of the spawn target.
159 #[must_use]
160 pub fn occupied(detail: impl Into<String>) -> Self {
161 Self::Occupied {
162 detail: detail.into(),
163 }
164 }
165
166 /// Builds a [`Self::Transport`] with a detail message.
167 #[must_use]
168 pub fn transport(detail: impl Into<String>) -> Self {
169 Self::Transport {
170 detail: detail.into(),
171 }
172 }
173
174 /// Builds a [`Self::Protocol`] with a detail message.
175 #[must_use]
176 pub fn protocol(detail: impl Into<String>) -> Self {
177 Self::Protocol {
178 detail: detail.into(),
179 }
180 }
181
182 /// Builds a [`Self::Harness`] with a detail message.
183 #[must_use]
184 pub fn harness(detail: impl Into<String>) -> Self {
185 Self::Harness {
186 detail: detail.into(),
187 }
188 }
189
190 /// Builds a [`Self::PolicyRefused`] with the adapter-owned refusal detail.
191 #[must_use]
192 pub fn policy_refused(detail: impl Into<String>, detection: PolicyRefusalDetection) -> Self {
193 Self::PolicyRefused {
194 detail: detail.into(),
195 detection,
196 }
197 }
198
199 /// Builds a [`Self::Contract`] with a detail message naming the canonical
200 /// agent-outcome contract and what was found instead.
201 #[must_use]
202 pub fn contract(detail: impl Into<String>) -> Self {
203 Self::Contract {
204 detail: detail.into(),
205 }
206 }
207
208 /// Builds a [`Self::Configuration`] with a detail message naming the setting that
209 /// makes the harness unlaunchable.
210 #[must_use]
211 pub fn configuration(detail: impl Into<String>) -> Self {
212 Self::Configuration {
213 detail: detail.into(),
214 }
215 }
216
217 /// Whether this error is DETERMINISTIC — a property of how the run is
218 /// configured, so retrying re-spends a whole agent run to hit the same
219 /// wall — as opposed to potentially transient (a provider-overload burst,
220 /// a one-off malformed frame, a dropped pipe, a superseded attempt).
221 ///
222 /// This is THE retry-classification decision for the seam, made here in
223 /// the defining crate with an EXHAUSTIVE match — legal despite
224 /// `#[non_exhaustive]` — so adding a variant is a compile error at this
225 /// site and its classification is decided on purpose, never defaulted by
226 /// a caller's wildcard arm. The worker maps `true` to a terminal activity
227 /// failure and `false` to a retryable one.
228 ///
229 /// Per variant:
230 /// - [`Self::Contract`]: deterministic by definition — the run completed
231 /// and its configured outcome shape cannot satisfy the agent-outcome
232 /// contract; the next attempt is configured identically.
233 /// - [`Self::Configuration`]: deterministic by definition — the launch was
234 /// refused by a value in the document, and the next attempt reads the
235 /// same document. A refusal that presented as a transient transport
236 /// failure would tell the operator the wrong story AND spend the whole
237 /// attempt budget confirming it.
238 /// - [`Self::Occupied`]: the spawn target is held by a live process, and
239 /// occupancy is transient by nature — the holder exits or dies, the
240 /// stale marker is cleaned, and the next attempt proceeds. This holds
241 /// across workflows too: sequential reuse of one tree waits out the
242 /// previous occupant rather than dying on it (issue #33 is the field
243 /// case for the terminal misclassification).
244 /// - [`Self::Transport`]: a broken channel can heal.
245 /// - [`Self::Protocol`]: a malformed frame CAN be a one-off peer flake
246 /// (truncated stream, interleaved write), so it stays retryable even
247 /// though some protocol faults are in fact permanent.
248 /// - [`Self::PolicyRefused`]: stochastic by measurement — the same act and
249 /// content have succeeded on a later attempt. The dedicated class is a
250 /// route-first control signal, not a claim that the refusal is permanent.
251 /// - [`Self::Harness`]: the run failed; overload and timeouts recur or
252 /// do not — that judgement belongs to the retry policy.
253 /// - [`Self::CapabilityNotSupported`] / [`Self::StaleTarget`]: gating and
254 /// staleness outcomes on the intervention path; when they surface from
255 /// a result path at all they describe a racing world, not a fixed one.
256 #[must_use]
257 pub fn is_deterministic(&self) -> bool {
258 match self {
259 Self::Contract { .. } | Self::Configuration { .. } => true,
260 Self::CapabilityNotSupported { .. }
261 | Self::StaleTarget { .. }
262 | Self::Occupied { .. }
263 | Self::Transport { .. }
264 | Self::Protocol { .. }
265 | Self::PolicyRefused { .. }
266 | Self::Harness { .. } => false,
267 }
268 }
269}
270
271#[cfg(test)]
272mod tests {
273 use super::HarnessError;
274
275 fn assert_send_sync_static<T: Send + Sync + 'static>() {}
276
277 #[test]
278 fn harness_error_is_send_sync_static() {
279 assert_send_sync_static::<HarnessError>();
280 }
281
282 #[test]
283 fn capability_not_supported_names_the_primitive() {
284 let error = HarnessError::capability_not_supported("pause_resume");
285 assert_eq!(error.to_string(), "capability not supported: pause_resume");
286 assert!(matches!(error, HarnessError::CapabilityNotSupported { .. }));
287 }
288
289 #[test]
290 fn each_constructor_renders_its_class() {
291 assert_eq!(
292 HarnessError::stale_target("attempt 2 superseded").to_string(),
293 "stale target: attempt 2 superseded"
294 );
295 assert_eq!(
296 HarnessError::occupied("live sibling pid 7 holds this tree").to_string(),
297 "spawn target occupied: live sibling pid 7 holds this tree"
298 );
299 assert_eq!(
300 HarnessError::transport("broken pipe").to_string(),
301 "transport error: broken pipe"
302 );
303 assert_eq!(
304 HarnessError::protocol("no matching id").to_string(),
305 "protocol error: no matching id"
306 );
307 assert_eq!(
308 HarnessError::harness("exit code 1").to_string(),
309 "harness reported failure: exit code 1"
310 );
311 assert_eq!(
312 HarnessError::contract("output is a JSON object").to_string(),
313 "agent-outcome contract refusal: output is a JSON object"
314 );
315 }
316
317 /// The retry-classification decision, pinned in the crate that makes it:
318 /// exactly the contract and configuration refusals are deterministic;
319 /// every class that can be transient stays non-deterministic. (The match
320 /// inside `is_deterministic` is exhaustive, so a new variant fails
321 /// compilation there — this test pins the ANSWERS, the compiler pins the
322 /// completeness.)
323 #[test]
324 fn deterministic_refusals_are_exactly_contract_and_configuration() {
325 assert!(HarnessError::contract("output is a JSON object").is_deterministic());
326 assert!(HarnessError::configuration("pass-through entry is KEY=VALUE").is_deterministic());
327 for transient in [
328 HarnessError::occupied("live sibling pid 7 holds this tree"),
329 HarnessError::transport("broken pipe"),
330 HarnessError::protocol("invalid JSON frame"),
331 HarnessError::policy_refused(
332 "provider rejected the request",
333 super::PolicyRefusalDetection::Structured,
334 ),
335 HarnessError::harness("run stopped without completing"),
336 HarnessError::stale_target("attempt 2 superseded"),
337 HarnessError::capability_not_supported("pause_resume"),
338 ] {
339 assert!(
340 !transient.is_deterministic(),
341 "{transient:?} can be transient and must not classify deterministic"
342 );
343 }
344 }
345}