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