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