Skip to main content

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 structured output where the contract's `text` demands a
130    /// String — so re-running it re-spends a whole agent run to hit the same wall. The worker
131    /// maps this variant to a TERMINAL activity failure; every other variant stays retryable.
132    #[error("agent-outcome contract refusal: {detail}")]
133    Contract {
134        /// Human-readable description naming the contract and what was found.
135        detail: String,
136    },
137}
138
139impl HarnessError {
140    /// Builds a [`Self::CapabilityNotSupported`] naming the unsupported primitive.
141    #[must_use]
142    pub fn capability_not_supported(primitive: impl Into<String>) -> Self {
143        Self::CapabilityNotSupported {
144            primitive: primitive.into(),
145        }
146    }
147
148    /// Builds a [`Self::StaleTarget`] with a detail message.
149    #[must_use]
150    pub fn stale_target(detail: impl Into<String>) -> Self {
151        Self::StaleTarget {
152            detail: detail.into(),
153        }
154    }
155
156    /// Builds a [`Self::Occupied`] with a detail message naming the live holder
157    /// of the spawn target.
158    #[must_use]
159    pub fn occupied(detail: impl Into<String>) -> Self {
160        Self::Occupied {
161            detail: detail.into(),
162        }
163    }
164
165    /// Builds a [`Self::Transport`] with a detail message.
166    #[must_use]
167    pub fn transport(detail: impl Into<String>) -> Self {
168        Self::Transport {
169            detail: detail.into(),
170        }
171    }
172
173    /// Builds a [`Self::Protocol`] with a detail message.
174    #[must_use]
175    pub fn protocol(detail: impl Into<String>) -> Self {
176        Self::Protocol {
177            detail: detail.into(),
178        }
179    }
180
181    /// Builds a [`Self::Harness`] with a detail message.
182    #[must_use]
183    pub fn harness(detail: impl Into<String>) -> Self {
184        Self::Harness {
185            detail: detail.into(),
186        }
187    }
188
189    /// Builds a [`Self::PolicyRefused`] with the adapter-owned refusal detail.
190    #[must_use]
191    pub fn policy_refused(detail: impl Into<String>, detection: PolicyRefusalDetection) -> Self {
192        Self::PolicyRefused {
193            detail: detail.into(),
194            detection,
195        }
196    }
197
198    /// Builds a [`Self::Contract`] with a detail message naming the canonical
199    /// agent-outcome contract and what was found instead.
200    #[must_use]
201    pub fn contract(detail: impl Into<String>) -> Self {
202        Self::Contract {
203            detail: detail.into(),
204        }
205    }
206
207    /// Builds a [`Self::Configuration`] with a detail message naming the setting that
208    /// makes the harness unlaunchable.
209    #[must_use]
210    pub fn configuration(detail: impl Into<String>) -> Self {
211        Self::Configuration {
212            detail: detail.into(),
213        }
214    }
215
216    /// Whether this error is DETERMINISTIC — a property of how the run is
217    /// configured, so retrying re-spends a whole agent run to hit the same
218    /// wall — as opposed to potentially transient (a provider-overload burst,
219    /// a one-off malformed frame, a dropped pipe, a superseded attempt).
220    ///
221    /// This is THE retry-classification decision for the seam, made here in
222    /// the defining crate with an EXHAUSTIVE match — legal despite
223    /// `#[non_exhaustive]` — so adding a variant is a compile error at this
224    /// site and its classification is decided on purpose, never defaulted by
225    /// a caller's wildcard arm. The worker maps `true` to a terminal activity
226    /// failure and `false` to a retryable one.
227    ///
228    /// Per variant:
229    /// - [`Self::Contract`]: deterministic by definition — the run completed
230    ///   and its configured outcome shape cannot satisfy the agent-outcome
231    ///   contract; the next attempt is configured identically.
232    /// - [`Self::Configuration`]: deterministic by definition — the launch was
233    ///   refused by a value in the document, and the next attempt reads the
234    ///   same document. A refusal that presented as a transient transport
235    ///   failure would tell the operator the wrong story AND spend the whole
236    ///   attempt budget confirming it.
237    /// - [`Self::Occupied`]: the spawn target is held by a live process, and
238    ///   occupancy is transient by nature — the holder exits or dies, the
239    ///   stale marker is cleaned, and the next attempt proceeds. This holds
240    ///   across workflows too: sequential reuse of one tree waits out the
241    ///   previous occupant rather than dying on it (issue #33 is the field
242    ///   case for the terminal misclassification).
243    /// - [`Self::Transport`]: a broken channel can heal.
244    /// - [`Self::Protocol`]: a malformed frame CAN be a one-off peer flake
245    ///   (truncated stream, interleaved write), so it stays retryable even
246    ///   though some protocol faults are in fact permanent.
247    /// - [`Self::PolicyRefused`]: stochastic by measurement — the same act and
248    ///   content have succeeded on a later attempt. The dedicated class is a
249    ///   route-first control signal, not a claim that the refusal is permanent.
250    /// - [`Self::Harness`]: the run failed; overload and timeouts recur or
251    ///   do not — that judgement belongs to the retry policy.
252    /// - [`Self::CapabilityNotSupported`] / [`Self::StaleTarget`]: gating and
253    ///   staleness outcomes on the intervention path; when they surface from
254    ///   a result path at all they describe a racing world, not a fixed one.
255    #[must_use]
256    pub fn is_deterministic(&self) -> bool {
257        match self {
258            Self::Contract { .. } | Self::Configuration { .. } => true,
259            Self::CapabilityNotSupported { .. }
260            | Self::StaleTarget { .. }
261            | Self::Occupied { .. }
262            | Self::Transport { .. }
263            | Self::Protocol { .. }
264            | Self::PolicyRefused { .. }
265            | Self::Harness { .. } => false,
266        }
267    }
268}
269
270#[cfg(test)]
271mod tests {
272    use super::HarnessError;
273
274    fn assert_send_sync_static<T: Send + Sync + 'static>() {}
275
276    #[test]
277    fn harness_error_is_send_sync_static() {
278        assert_send_sync_static::<HarnessError>();
279    }
280
281    #[test]
282    fn capability_not_supported_names_the_primitive() {
283        let error = HarnessError::capability_not_supported("pause_resume");
284        assert_eq!(error.to_string(), "capability not supported: pause_resume");
285        assert!(matches!(error, HarnessError::CapabilityNotSupported { .. }));
286    }
287
288    #[test]
289    fn each_constructor_renders_its_class() {
290        assert_eq!(
291            HarnessError::stale_target("attempt 2 superseded").to_string(),
292            "stale target: attempt 2 superseded"
293        );
294        assert_eq!(
295            HarnessError::occupied("live sibling pid 7 holds this tree").to_string(),
296            "spawn target occupied: live sibling pid 7 holds this tree"
297        );
298        assert_eq!(
299            HarnessError::transport("broken pipe").to_string(),
300            "transport error: broken pipe"
301        );
302        assert_eq!(
303            HarnessError::protocol("no matching id").to_string(),
304            "protocol error: no matching id"
305        );
306        assert_eq!(
307            HarnessError::harness("exit code 1").to_string(),
308            "harness reported failure: exit code 1"
309        );
310        assert_eq!(
311            HarnessError::contract("output is a JSON object").to_string(),
312            "agent-outcome contract refusal: output is a JSON object"
313        );
314    }
315
316    /// The retry-classification decision, pinned in the crate that makes it:
317    /// exactly the contract and configuration refusals are deterministic;
318    /// every class that can be transient stays non-deterministic. (The match
319    /// inside `is_deterministic` is exhaustive, so a new variant fails
320    /// compilation there — this test pins the ANSWERS, the compiler pins the
321    /// completeness.)
322    #[test]
323    fn deterministic_refusals_are_exactly_contract_and_configuration() {
324        assert!(HarnessError::contract("output is a JSON object").is_deterministic());
325        assert!(HarnessError::configuration("pass-through entry is KEY=VALUE").is_deterministic());
326        for transient in [
327            HarnessError::occupied("live sibling pid 7 holds this tree"),
328            HarnessError::transport("broken pipe"),
329            HarnessError::protocol("invalid JSON frame"),
330            HarnessError::policy_refused(
331                "provider rejected the request",
332                super::PolicyRefusalDetection::Structured,
333            ),
334            HarnessError::harness("run stopped without completing"),
335            HarnessError::stale_target("attempt 2 superseded"),
336            HarnessError::capability_not_supported("pause_resume"),
337        ] {
338            assert!(
339                !transient.is_deterministic(),
340                "{transient:?} can be transient and must not classify deterministic"
341            );
342        }
343    }
344}