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