Skip to main content

a3s_code_core/
error.rs

1//! Typed error enum for A3S Code Core
2//!
3//! Provides categorized errors that SDK consumers can match on programmatically,
4//! instead of receiving opaque `anyhow::Error` strings.
5//!
6//! ## Migration Strategy
7//!
8//! The `Internal` variant wraps `anyhow::Error` via `#[from]`, allowing
9//! gradual migration: call sites that haven't been updated yet auto-convert
10//! through `?`. Over time, each call site replaces `anyhow::anyhow!(...)`
11//! with a specific variant like `CodeError::Config(...)`.
12
13use thiserror::Error;
14
15/// Async resource whose initialization is part of building a session.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum SessionBuildResource {
18    Capability,
19    MemoryStore,
20    SessionStore,
21    Queue,
22    Mcp,
23    RlTrajectory,
24    WorkspaceRetrieval,
25}
26
27impl std::fmt::Display for SessionBuildResource {
28    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29        f.write_str(match self {
30            Self::Capability => "capability runtime",
31            Self::MemoryStore => "memory store",
32            Self::SessionStore => "session store",
33            Self::Queue => "session queue",
34            Self::Mcp => "MCP",
35            Self::RlTrajectory => "RL trajectory recorder",
36            Self::WorkspaceRetrieval => "workspace retrieval",
37        })
38    }
39}
40
41/// Crate-wide result type alias.
42pub type Result<T> = std::result::Result<T, CodeError>;
43
44/// Categorized error type for A3S Code Core.
45///
46/// SDK bindings (Python/Node) can match on the variant to expose typed
47/// exceptions (e.g., `CodeConfigError`, `CodeLlmError`).
48#[derive(Debug, Error)]
49pub enum CodeError {
50    /// Configuration loading or parsing error
51    #[error("Config error: {0}")]
52    Config(String),
53
54    /// LLM provider communication error
55    #[error("LLM error: {0}")]
56    Llm(String),
57
58    /// Tool execution error
59    #[error("Tool error: {tool}: {message}")]
60    Tool { tool: String, message: String },
61
62    /// Session management error
63    #[error("Session error: {0}")]
64    Session(String),
65
66    /// A session option is missing, malformed, or conflicts with another option.
67    #[error("Invalid session configuration for '{field}': {message}")]
68    SessionConfiguration {
69        field: &'static str,
70        message: String,
71    },
72
73    /// A session resource could not be initialized.
74    #[error("Failed to initialize {resource}: {message}")]
75    SessionInitialization {
76        resource: SessionBuildResource,
77        message: String,
78    },
79
80    /// The synchronous compatibility factory was asked to initialize an
81    /// async-only resource. Call `Agent::session_builder(...).build().await`.
82    #[error(
83        "{resource} requires asynchronous session construction; use Agent::session_builder(...).build().await"
84    )]
85    AsyncSessionBuildRequired { resource: SessionBuildResource },
86
87    /// Session has been closed; further operations are rejected.
88    ///
89    /// Returned by `send`/`stream` (and their variants) after
90    /// [`AgentSession::close`](crate::agent_api::AgentSession::close)
91    /// — or [`Agent::close`](crate::agent_api::Agent::close) — has been called.
92    #[error("Session '{session_id}' is closed")]
93    SessionClosed { session_id: String },
94
95    /// Another conversation operation is already active on this session.
96    ///
97    /// Sessions serialize conversation state, so callers must wait for the
98    /// active operation's returned future or stream handle to finish before
99    /// starting another one.
100    #[error("Session '{session_id}' already has an active operation")]
101    SessionBusy { session_id: String },
102
103    /// Global task admission was cancelled before an execution slot opened.
104    #[error("Task admission for session '{session_id}' was cancelled")]
105    TaskAdmissionCancelled { session_id: String },
106
107    /// The owning agent's global task scheduler no longer accepts work.
108    #[error("Task scheduler is closed")]
109    TaskSchedulerClosed,
110
111    /// A host replayed a run id with different immutable session or input
112    /// identity. The existing run is preserved and no work is started.
113    #[error("Run '{run_id}' is already bound to different immutable input")]
114    RunIdentityConflict { run_id: String },
115
116    /// A host-supplied [`BudgetGuard`](crate::budget::BudgetGuard) denied
117    /// the operation. The session is not closed — callers can re-try
118    /// after the host has re-allocated budget.
119    #[error("Budget exhausted on '{resource}': {reason}")]
120    BudgetExhausted { resource: String, reason: String },
121
122    /// Security subsystem error
123    #[error("Security error: {0}")]
124    Security(String),
125
126    /// Context provider or context store error
127    #[error("Context error: {0}")]
128    Context(String),
129
130    /// MCP (Model Context Protocol) error
131    #[error("MCP error: {0}")]
132    Mcp(String),
133
134    /// Queue or lane error
135    #[error("Queue error: {0}")]
136    Queue(String),
137
138    /// Atomic Session capability publication or Run admission failure.
139    #[error("Capability runtime error: {0}")]
140    Capability(#[from] crate::capability::CapabilityRuntimeError),
141
142    /// A3S Flow definition, replay, store, or runtime failure.
143    #[error("Flow error: {0}")]
144    Flow(#[from] a3s_flow::FlowError),
145
146    /// I/O error
147    #[error("IO error: {0}")]
148    Io(#[from] std::io::Error),
149
150    /// JSON serialization/deserialization error
151    #[error("Serialization error: {0}")]
152    Serialization(#[from] serde_json::Error),
153
154    /// Catch-all for errors not yet migrated to a specific variant.
155    ///
156    /// The `#[from] anyhow::Error` conversion enables gradual migration:
157    /// any function returning `anyhow::Result` can be called with `?` from
158    /// a function returning `crate::error::Result` without changes.
159    #[error("{0:#}")]
160    Internal(#[from] anyhow::Error),
161}
162
163impl CodeError {
164    /// Stable machine-readable code for SDK and service boundaries.
165    pub const fn code(&self) -> &'static str {
166        match self {
167            Self::Config(_) => "CONFIG_ERROR",
168            Self::Llm(_) => "LLM_ERROR",
169            Self::Tool { .. } => "TOOL_ERROR",
170            Self::Session(_) => "SESSION_ERROR",
171            Self::SessionConfiguration { .. } => "SESSION_CONFIGURATION_ERROR",
172            Self::SessionInitialization { .. } => "SESSION_INITIALIZATION_ERROR",
173            Self::AsyncSessionBuildRequired { .. } => "ASYNC_SESSION_BUILD_REQUIRED",
174            Self::SessionClosed { .. } => "SESSION_CLOSED",
175            Self::SessionBusy { .. } => "SESSION_BUSY",
176            Self::TaskAdmissionCancelled { .. } => "TASK_ADMISSION_CANCELLED",
177            Self::TaskSchedulerClosed => "TASK_SCHEDULER_CLOSED",
178            Self::RunIdentityConflict { .. } => "RUN_IDENTITY_CONFLICT",
179            Self::BudgetExhausted { .. } => "BUDGET_EXHAUSTED",
180            Self::Security(_) => "SECURITY_ERROR",
181            Self::Context(_) => "CONTEXT_ERROR",
182            Self::Mcp(_) => "MCP_ERROR",
183            Self::Queue(_) => "QUEUE_ERROR",
184            Self::Capability(_) => "CAPABILITY_RUNTIME_ERROR",
185            Self::Flow(_) => "FLOW_ERROR",
186            Self::Io(_) => "IO_ERROR",
187            Self::Serialization(_) => "SERIALIZATION_ERROR",
188            Self::Internal(_) => "INTERNAL_ERROR",
189        }
190    }
191}
192
193// ============================================================================
194// Lock Poisoning Helpers (Phase 3b)
195// ============================================================================
196
197/// Acquire a read guard, recovering from poison if the lock was poisoned.
198///
199/// Non-security code should never panic on a poisoned lock. The data may
200/// be in an inconsistent state, but crashing the entire process is worse
201/// than serving stale data in a coding agent context.
202pub(crate) fn read_or_recover<T>(lock: &std::sync::RwLock<T>) -> std::sync::RwLockReadGuard<'_, T> {
203    lock.read().unwrap_or_else(|p| p.into_inner())
204}
205
206/// Acquire a write guard, recovering from poison if the lock was poisoned.
207///
208/// See [`read_or_recover`] for rationale.
209pub(crate) fn write_or_recover<T>(
210    lock: &std::sync::RwLock<T>,
211) -> std::sync::RwLockWriteGuard<'_, T> {
212    lock.write().unwrap_or_else(|p| p.into_inner())
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    #[test]
220    fn test_code_error_config() {
221        let err = CodeError::Config("missing API key".to_string());
222        assert!(err.to_string().contains("Config error"));
223        assert!(err.to_string().contains("missing API key"));
224    }
225
226    #[test]
227    fn test_code_error_llm() {
228        let err = CodeError::Llm("rate limited".to_string());
229        assert!(err.to_string().contains("LLM error"));
230    }
231
232    #[test]
233    fn test_code_error_tool() {
234        let err = CodeError::Tool {
235            tool: "bash".to_string(),
236            message: "command not found".to_string(),
237        };
238        let msg = err.to_string();
239        assert!(msg.contains("bash"));
240        assert!(msg.contains("command not found"));
241    }
242
243    #[test]
244    fn test_code_error_session() {
245        let err = CodeError::Session("not found".to_string());
246        assert!(err.to_string().contains("Session error"));
247    }
248
249    #[test]
250    fn test_code_error_session_configuration_keeps_field_identity() {
251        let err = CodeError::SessionConfiguration {
252            field: "session_id",
253            message: "must not be empty".to_string(),
254        };
255        assert!(err.to_string().contains("session_id"));
256        assert!(err.to_string().contains("must not be empty"));
257    }
258
259    #[test]
260    fn test_code_error_session_busy() {
261        let err = CodeError::SessionBusy {
262            session_id: "session-1".to_string(),
263        };
264        assert!(err.to_string().contains("session-1"));
265        assert!(err.to_string().contains("active operation"));
266    }
267
268    #[test]
269    fn test_code_error_security() {
270        let err = CodeError::Security("taint detected".to_string());
271        assert!(err.to_string().contains("Security error"));
272    }
273
274    #[test]
275    fn test_code_error_context() {
276        let err = CodeError::Context("provider failed".to_string());
277        assert!(err.to_string().contains("Context error"));
278    }
279
280    #[test]
281    fn test_code_error_mcp() {
282        let err = CodeError::Mcp("connection refused".to_string());
283        assert!(err.to_string().contains("MCP error"));
284    }
285
286    #[test]
287    fn test_code_error_queue() {
288        let err = CodeError::Queue("lane full".to_string());
289        assert!(err.to_string().contains("Queue error"));
290    }
291
292    #[test]
293    fn test_code_error_from_io() {
294        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file missing");
295        let err: CodeError = io_err.into();
296        assert!(matches!(err, CodeError::Io(_)));
297        assert!(err.to_string().contains("file missing"));
298    }
299
300    #[test]
301    fn test_code_error_from_serde_json() {
302        let json_err = serde_json::from_str::<serde_json::Value>("invalid").unwrap_err();
303        let err: CodeError = json_err.into();
304        assert!(matches!(err, CodeError::Serialization(_)));
305    }
306
307    #[test]
308    fn test_code_error_from_anyhow() {
309        let anyhow_err = anyhow::anyhow!("something went wrong");
310        let err: CodeError = anyhow_err.into();
311        assert!(matches!(err, CodeError::Internal(_)));
312        assert!(err.to_string().contains("something went wrong"));
313    }
314
315    #[test]
316    fn stable_error_codes_cover_control_flow_variants() {
317        assert_eq!(
318            CodeError::SessionBusy {
319                session_id: "session-1".to_string(),
320            }
321            .code(),
322            "SESSION_BUSY"
323        );
324        assert_eq!(
325            CodeError::SessionClosed {
326                session_id: "session-1".to_string(),
327            }
328            .code(),
329            "SESSION_CLOSED"
330        );
331        assert_eq!(
332            CodeError::RunIdentityConflict {
333                run_id: "run-1".to_string(),
334            }
335            .code(),
336            "RUN_IDENTITY_CONFLICT"
337        );
338        assert_eq!(
339            CodeError::BudgetExhausted {
340                resource: "tokens".to_string(),
341                reason: "limit".to_string(),
342            }
343            .code(),
344            "BUDGET_EXHAUSTED"
345        );
346        assert_eq!(
347            CodeError::TaskAdmissionCancelled {
348                session_id: "session-1".to_string(),
349            }
350            .code(),
351            "TASK_ADMISSION_CANCELLED"
352        );
353        assert_eq!(
354            CodeError::TaskSchedulerClosed.code(),
355            "TASK_SCHEDULER_CLOSED"
356        );
357    }
358
359    #[test]
360    fn test_code_error_question_mark_from_anyhow() {
361        fn inner() -> anyhow::Result<()> {
362            anyhow::bail!("inner error")
363        }
364
365        fn outer() -> Result<()> {
366            inner()?; // anyhow::Error -> CodeError::Internal via #[from]
367            Ok(())
368        }
369
370        let result = outer();
371        assert!(result.is_err());
372        let err = result.unwrap_err();
373        assert!(matches!(err, CodeError::Internal(_)));
374    }
375
376    #[test]
377    fn test_read_or_recover_normal() {
378        let lock = std::sync::RwLock::new(42);
379        let guard = read_or_recover(&lock);
380        assert_eq!(*guard, 42);
381    }
382
383    #[test]
384    fn test_write_or_recover_normal() {
385        let lock = std::sync::RwLock::new(42);
386        let mut guard = write_or_recover(&lock);
387        *guard = 99;
388        drop(guard);
389        assert_eq!(*read_or_recover(&lock), 99);
390    }
391
392    #[test]
393    fn test_read_or_recover_poisoned() {
394        let lock = std::sync::RwLock::new(42);
395        // Poison the lock by panicking while holding a write guard
396        let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
397            let _guard = lock.write().unwrap();
398            panic!("intentional poison");
399        }));
400        // Should recover without panicking
401        let guard = read_or_recover(&lock);
402        assert_eq!(*guard, 42);
403    }
404
405    #[test]
406    fn test_write_or_recover_poisoned() {
407        let lock = std::sync::RwLock::new(42);
408        let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
409            let _guard = lock.write().unwrap();
410            panic!("intentional poison");
411        }));
412        let mut guard = write_or_recover(&lock);
413        *guard = 100;
414        assert_eq!(*guard, 100);
415    }
416}