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