1use harn_lexer::Span;
2
3use super::{VmDictExt, VmValue};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum ArityExpect {
10 Exact(usize),
12 Range { min: usize, max: usize },
14 AtLeast(usize),
17}
18
19impl std::fmt::Display for ArityExpect {
20 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
21 match self {
22 ArityExpect::Exact(n) => write!(f, "{n}"),
23 ArityExpect::Range { min, max } => write!(f, "{min}..={max}"),
24 ArityExpect::AtLeast(n) => write!(f, "at least {n}"),
25 }
26 }
27}
28
29#[derive(Debug, Clone)]
30pub struct ArityMismatchError {
31 pub callee: String,
32 pub expected: ArityExpect,
33 pub got: usize,
34 pub span: Option<Span>,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum DeadlockDiagnostic {
39 SelfDeadlock,
40 WaitForGraph,
41}
42
43impl DeadlockDiagnostic {
44 fn code(self) -> &'static str {
45 match self {
46 Self::SelfDeadlock => "HARN-ORC-011",
47 Self::WaitForGraph => "HARN-ORC-012",
48 }
49 }
50}
51
52#[derive(Debug, Clone)]
56pub struct DeadlockError {
57 pub diagnostic: DeadlockDiagnostic,
58 pub kind: String,
59 pub key: String,
60 pub detail: String,
61}
62
63impl DeadlockError {
64 pub(crate) fn self_deadlock(
65 kind: impl Into<String>,
66 key: impl Into<String>,
67 detail: impl Into<String>,
68 ) -> Self {
69 Self {
70 diagnostic: DeadlockDiagnostic::SelfDeadlock,
71 kind: kind.into(),
72 key: key.into(),
73 detail: detail.into(),
74 }
75 }
76
77 pub(crate) fn wait_for_graph(
78 kind: impl Into<String>,
79 key: impl Into<String>,
80 detail: impl Into<String>,
81 ) -> Self {
82 Self {
83 diagnostic: DeadlockDiagnostic::WaitForGraph,
84 kind: kind.into(),
85 key: key.into(),
86 detail: detail.into(),
87 }
88 }
89}
90
91#[derive(Debug, Clone)]
92pub struct ArgTypeMismatchError {
93 pub callee: String,
94 pub param: String,
95 pub expected: String,
96 pub got: &'static str,
97 pub span: Option<Span>,
98}
99
100#[derive(Debug, Clone)]
101pub enum VmError {
102 StackUnderflow,
103 StackOverflow,
104 UndefinedVariable(String),
105 UndefinedBuiltin(String),
106 ImmutableAssignment(String),
107 TypeError(String),
108 Runtime(String),
109 DivisionByZero,
110 ExecutionDeadlineExceeded,
113 ProcessExit(i32),
118 AbandonedExecution,
123 McpInputRequired(Box<crate::mcp_input::McpInputRequired>),
127 Thrown(VmValue),
128 CategorizedError {
130 message: String,
131 category: ErrorCategory,
132 },
133 ProviderStreamFailure(Box<ProviderStreamFailure>),
138 DaemonQueueFull {
139 daemon_id: String,
140 capacity: usize,
141 },
142 Deadlock(Box<DeadlockError>),
149 Return(VmValue),
150 InvalidInstruction(u8),
151 ArityMismatch(Box<ArityMismatchError>),
155 ArgTypeMismatch(Box<ArgTypeMismatchError>),
161}
162
163impl VmError {
164 pub fn is_uncatchable_control_flow(&self) -> bool {
167 matches!(
168 self,
169 Self::ExecutionDeadlineExceeded | Self::ProcessExit(_) | Self::McpInputRequired(_)
170 )
171 }
172
173 pub fn process_exit_code(&self) -> Option<i32> {
176 match self {
177 Self::ProcessExit(code) => Some(*code),
178 _ => None,
179 }
180 }
181
182 pub fn thrown_value(&self) -> VmValue {
203 match self {
204 VmError::Thrown(v) => v.clone(),
205 VmError::CategorizedError { message, category } => {
206 let mut dict = std::collections::BTreeMap::new();
207 dict.put_str("category", category.as_str());
208 dict.put_str("message", message);
209 VmValue::dict(dict)
210 }
211 VmError::ProviderStreamFailure(failure) => failure.thrown_value(),
212 other => VmValue::String(arcstr::ArcStr::from(other.to_string())),
213 }
214 }
215
216 pub fn provider_stream_failure(&self) -> Option<&ProviderStreamFailure> {
217 match self {
218 Self::ProviderStreamFailure(failure) => Some(failure),
219 _ => None,
220 }
221 }
222}
223
224#[derive(Debug, Clone, Copy, PartialEq, Eq)]
225pub enum ProviderStreamPhase {
226 AwaitingFirstChunk,
227 Streaming,
228}
229
230impl ProviderStreamPhase {
231 pub fn as_str(self) -> &'static str {
232 match self {
233 Self::AwaitingFirstChunk => "awaiting_first_chunk",
234 Self::Streaming => "streaming",
235 }
236 }
237}
238
239#[derive(Debug, Clone, Copy, PartialEq, Eq)]
240pub enum ProviderStreamFailureReason {
241 Read,
242 PrematureEof,
243 Deadline,
244}
245
246impl ProviderStreamFailureReason {
247 pub fn as_str(self) -> &'static str {
248 match self {
249 Self::Read => "read",
250 Self::PrematureEof => "premature_eof",
251 Self::Deadline => "deadline",
252 }
253 }
254}
255
256#[derive(Debug, Clone, Copy, PartialEq, Eq)]
257pub enum ProviderStreamDeadline {
258 Total,
259 FirstChunk,
260 Idle,
261}
262
263impl ProviderStreamDeadline {
264 pub fn as_str(self) -> &'static str {
265 match self {
266 Self::Total => "total",
267 Self::FirstChunk => "first_chunk",
268 Self::Idle => "idle",
269 }
270 }
271}
272
273#[derive(Debug, Clone, PartialEq, Eq)]
274pub struct ProviderStreamFailure {
275 pub provider: String,
276 pub phase: ProviderStreamPhase,
277 pub reason: ProviderStreamFailureReason,
278 pub deadline: Option<ProviderStreamDeadline>,
279 pub partial: bool,
280 pub detail: String,
281}
282
283impl ProviderStreamFailure {
284 pub fn category(&self) -> ErrorCategory {
285 if self.deadline.is_some() {
286 ErrorCategory::Timeout
287 } else {
288 ErrorCategory::TransientNetwork
289 }
290 }
291
292 fn thrown_value(&self) -> VmValue {
293 let mut dict = std::collections::BTreeMap::new();
294 dict.put_str("category", self.category().as_str());
295 dict.put_str("message", self.to_string());
296 dict.put_str("source", "provider_stream");
297 dict.put_str("phase", self.phase.as_str());
298 dict.put_str("reason", self.reason.as_str());
299 dict.insert(
300 "deadline".to_string(),
301 self.deadline
302 .map(|deadline| VmValue::String(arcstr::ArcStr::from(deadline.as_str())))
303 .unwrap_or(VmValue::Nil),
304 );
305 dict.insert("partial".to_string(), VmValue::Bool(self.partial));
306 VmValue::dict(dict)
307 }
308}
309
310impl std::fmt::Display for ProviderStreamFailure {
311 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
312 write!(
313 f,
314 "{} provider stream failure (phase={}, reason={}",
315 self.provider,
316 self.phase.as_str(),
317 self.reason.as_str()
318 )?;
319 if let Some(deadline) = self.deadline {
320 write!(f, ", deadline={}", deadline.as_str())?;
321 }
322 write!(f, ", partial={}): {}", self.partial, self.detail)
323 }
324}
325
326#[derive(Debug, Clone, PartialEq, Eq)]
328pub enum ErrorCategory {
329 Timeout,
331 Auth,
333 RateLimit,
335 Overloaded,
339 ServerError,
341 TransientNetwork,
344 ResourceBusy,
347 SchemaIncompatible,
350 SchemaValidation,
352 SchemaStreamAborted,
359 ToolError,
361 ToolRejected,
363 EgressBlocked,
365 Cancelled,
367 ChannelClosed,
369 NotFound,
371 CircuitOpen,
373 BudgetExceeded,
375 Internal,
383 Environment,
392 Generic,
394}
395
396impl ErrorCategory {
397 pub const ALL: [Self; 21] = [
406 Self::Timeout,
407 Self::Auth,
408 Self::RateLimit,
409 Self::Overloaded,
410 Self::ServerError,
411 Self::TransientNetwork,
412 Self::ResourceBusy,
413 Self::SchemaIncompatible,
414 Self::SchemaValidation,
415 Self::SchemaStreamAborted,
416 Self::ToolError,
417 Self::ToolRejected,
418 Self::EgressBlocked,
419 Self::Cancelled,
420 Self::ChannelClosed,
421 Self::NotFound,
422 Self::CircuitOpen,
423 Self::BudgetExceeded,
424 Self::Internal,
425 Self::Environment,
426 Self::Generic,
427 ];
428
429 pub fn as_str(&self) -> &'static str {
430 match self {
431 ErrorCategory::Timeout => "timeout",
432 ErrorCategory::Auth => "auth",
433 ErrorCategory::RateLimit => "rate_limit",
434 ErrorCategory::Overloaded => "overloaded",
435 ErrorCategory::ServerError => "server_error",
436 ErrorCategory::TransientNetwork => "transient_network",
437 ErrorCategory::ResourceBusy => "resource_busy",
438 ErrorCategory::SchemaIncompatible => "schema_incompatible",
439 ErrorCategory::SchemaValidation => "schema_validation",
440 ErrorCategory::SchemaStreamAborted => "schema_stream_aborted",
441 ErrorCategory::ToolError => "tool_error",
442 ErrorCategory::ToolRejected => "tool_rejected",
443 ErrorCategory::EgressBlocked => "egress_blocked",
444 ErrorCategory::Cancelled => "cancelled",
445 ErrorCategory::ChannelClosed => "channel_closed",
446 ErrorCategory::NotFound => "not_found",
447 ErrorCategory::CircuitOpen => "circuit_open",
448 ErrorCategory::BudgetExceeded => "budget_exceeded",
449 ErrorCategory::Internal => "internal",
450 ErrorCategory::Environment => "environment",
451 ErrorCategory::Generic => "generic",
452 }
453 }
454
455 pub fn parse(s: &str) -> Self {
456 match s {
457 "timeout" => ErrorCategory::Timeout,
458 "auth" => ErrorCategory::Auth,
459 "rate_limit" => ErrorCategory::RateLimit,
460 "overloaded" => ErrorCategory::Overloaded,
461 "server_error" => ErrorCategory::ServerError,
462 "transient_network" => ErrorCategory::TransientNetwork,
463 "resource_busy" => ErrorCategory::ResourceBusy,
464 "schema_incompatible" => ErrorCategory::SchemaIncompatible,
465 "schema_validation" => ErrorCategory::SchemaValidation,
466 "schema_stream_aborted" => ErrorCategory::SchemaStreamAborted,
467 "tool_error" => ErrorCategory::ToolError,
468 "tool_rejected" => ErrorCategory::ToolRejected,
469 "egress_blocked" => ErrorCategory::EgressBlocked,
470 "cancelled" => ErrorCategory::Cancelled,
471 "channel_closed" => ErrorCategory::ChannelClosed,
472 "not_found" => ErrorCategory::NotFound,
473 "circuit_open" => ErrorCategory::CircuitOpen,
474 "budget_exceeded" => ErrorCategory::BudgetExceeded,
475 "internal" => ErrorCategory::Internal,
476 "environment" => ErrorCategory::Environment,
477 _ => ErrorCategory::Generic,
478 }
479 }
480
481 pub fn is_internal(&self) -> bool {
484 matches!(self, ErrorCategory::Internal)
485 }
486
487 pub fn is_transient(&self) -> bool {
491 matches!(
492 self,
493 ErrorCategory::Timeout
494 | ErrorCategory::RateLimit
495 | ErrorCategory::Overloaded
496 | ErrorCategory::ServerError
497 | ErrorCategory::TransientNetwork
498 | ErrorCategory::ResourceBusy
499 )
500 }
501}
502
503pub fn categorized_error(message: impl Into<String>, category: ErrorCategory) -> VmError {
505 VmError::CategorizedError {
506 message: message.into(),
507 category,
508 }
509}
510
511pub fn error_to_category(err: &VmError) -> ErrorCategory {
520 match err {
521 VmError::ExecutionDeadlineExceeded => ErrorCategory::Timeout,
522 VmError::ProcessExit(_) => ErrorCategory::Generic,
526 VmError::AbandonedExecution => ErrorCategory::Cancelled,
527 VmError::CategorizedError { category, .. } => category.clone(),
528 VmError::ProviderStreamFailure(failure) => failure.category(),
529 VmError::Thrown(VmValue::Dict(d)) => d
530 .get("category")
531 .map(|v| ErrorCategory::parse(&v.display()))
532 .unwrap_or(ErrorCategory::Generic),
533 VmError::Thrown(VmValue::String(s)) => classify_error_message(s),
534 VmError::Runtime(msg) => classify_error_message(msg),
535 VmError::UndefinedBuiltin(_) | VmError::InvalidInstruction(_) => ErrorCategory::Internal,
540 VmError::Deadlock(_) => ErrorCategory::Generic,
543 _ => ErrorCategory::Generic,
544 }
545}
546
547pub fn classify_error_message(msg: &str) -> ErrorCategory {
550 if let Some(cat) = classify_by_http_status(msg) {
552 return cat;
553 }
554 if msg.contains("Undefined builtin") {
559 return ErrorCategory::Internal;
560 }
561 let lower = msg.to_lowercase();
564 if lower.contains("cancelled") || lower.contains("canceled") {
565 return ErrorCategory::Cancelled;
566 }
567 if msg.contains("ChannelClosed") || lower.contains("channel closed") {
568 return ErrorCategory::ChannelClosed;
569 }
570 if msg.contains("Deadline exceeded") || msg.contains("context deadline exceeded") {
571 return ErrorCategory::Timeout;
572 }
573 if msg.contains("overloaded_error") {
574 return ErrorCategory::Overloaded;
576 }
577 if msg.contains("api_error") {
578 return ErrorCategory::ServerError;
580 }
581 if msg.contains("insufficient_quota") || msg.contains("billing_hard_limit_reached") {
582 return ErrorCategory::RateLimit;
584 }
585 if msg.contains("invalid_api_key") || msg.contains("authentication_error") {
586 return ErrorCategory::Auth;
587 }
588 if msg.contains("not_found_error") || msg.contains("model_not_found") {
589 return ErrorCategory::NotFound;
590 }
591 if lower.contains("is not a valid model id") || lower.contains("invalid model id") {
597 return ErrorCategory::NotFound;
598 }
599 if msg.contains("circuit_open") {
600 return ErrorCategory::CircuitOpen;
601 }
602 if lower.contains("connection reset")
604 || lower.contains("connection refused")
605 || lower.contains("connection closed")
606 || lower.contains("broken pipe")
607 || lower.contains("dns error")
608 || lower.contains("stream error")
609 || lower.contains("unexpected eof")
610 {
611 return ErrorCategory::TransientNetwork;
612 }
613 ErrorCategory::Generic
614}
615
616fn classify_by_http_status(msg: &str) -> Option<ErrorCategory> {
620 for code in extract_http_status_codes(msg) {
623 return Some(match code {
624 401 | 403 => ErrorCategory::Auth,
625 404 | 410 => ErrorCategory::NotFound,
626 408 | 504 | 522 | 524 => ErrorCategory::Timeout,
627 429 => ErrorCategory::RateLimit,
628 503 | 529 => ErrorCategory::Overloaded,
629 500 | 502 => ErrorCategory::ServerError,
630 _ => continue,
631 });
632 }
633 None
634}
635
636fn extract_http_status_codes(msg: &str) -> Vec<u16> {
638 let mut codes = Vec::new();
639 let bytes = msg.as_bytes();
640 for i in 0..bytes.len().saturating_sub(2) {
641 if bytes[i].is_ascii_digit()
643 && bytes[i + 1].is_ascii_digit()
644 && bytes[i + 2].is_ascii_digit()
645 {
646 let before_ok = i == 0 || !bytes[i - 1].is_ascii_digit();
648 let after_ok = i + 3 >= bytes.len() || !bytes[i + 3].is_ascii_digit();
649 if before_ok && after_ok {
650 if let Ok(code) = msg[i..i + 3].parse::<u16>() {
651 if (400..=599).contains(&code) {
652 codes.push(code);
653 }
654 }
655 }
656 }
657 }
658 codes
659}
660
661impl std::fmt::Display for VmError {
662 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
663 match self {
664 VmError::StackUnderflow => write!(f, "Stack underflow"),
665 VmError::StackOverflow => write!(f, "Stack overflow: too many nested calls"),
666 VmError::UndefinedVariable(n) => write!(f, "Undefined variable: {n}"),
667 VmError::UndefinedBuiltin(n) => write!(f, "Undefined builtin: {n}"),
668 VmError::ImmutableAssignment(n) => {
669 write!(f, "Cannot assign to immutable binding: {n}")
670 }
671 VmError::TypeError(msg) => write!(f, "Type error: {msg}"),
672 VmError::Runtime(msg) => write!(f, "Runtime error: {msg}"),
673 VmError::DivisionByZero => write!(f, "Division by zero"),
674 VmError::ExecutionDeadlineExceeded => write!(f, "Execution deadline exceeded"),
675 VmError::ProcessExit(code) => write!(f, "Process exit requested: {code}"),
676 VmError::AbandonedExecution => write!(
677 f,
678 "Execution future was abandoned; discard this VM and reset its exclusively owned execution context"
679 ),
680 VmError::McpInputRequired(_) => write!(f, "MCP client input required"),
681 VmError::Thrown(v) => write!(f, "Thrown: {}", v.display()),
682 VmError::CategorizedError { message, category } => {
683 write!(f, "Error [{}]: {}", category.as_str(), message)
684 }
685 VmError::ProviderStreamFailure(failure) => failure.fmt(f),
686 VmError::DaemonQueueFull {
687 daemon_id,
688 capacity,
689 } => write!(
690 f,
691 "Daemon queue full: daemon '{daemon_id}' reached its event_queue_capacity of {capacity}"
692 ),
693 VmError::Deadlock(err) => match err.diagnostic {
694 DeadlockDiagnostic::SelfDeadlock => write!(
695 f,
696 "{}: deadlock detected: {} ({} '{}') — this wait can never complete and would block forever",
697 err.diagnostic.code(),
698 err.detail,
699 err.kind,
700 err.key
701 ),
702 DeadlockDiagnostic::WaitForGraph => write!(
703 f,
704 "{}: wait-for deadlock detected: {} ({} '{}') — no active task can make progress",
705 err.diagnostic.code(),
706 err.detail,
707 err.kind,
708 err.key
709 ),
710 },
711 VmError::Return(_) => write!(f, "Return from function"),
712 VmError::InvalidInstruction(op) => write!(f, "Invalid instruction: 0x{op:02x}"),
713 VmError::ArityMismatch(err) => {
714 let arg_word = match err.expected {
715 ArityExpect::Exact(1) | ArityExpect::AtLeast(1) => "argument",
716 _ => "arguments",
717 };
718 write!(
719 f,
720 "Arity mismatch: '{}' expects {} {}, got {}{}",
721 err.callee,
722 err.expected,
723 arg_word,
724 err.got,
725 fmt_span_suffix(&err.span)
726 )
727 }
728 VmError::ArgTypeMismatch(err) => {
729 write!(
730 f,
731 "Type error: '{}' parameter `{}` expects {}, got {}{}",
732 err.callee,
733 err.param,
734 err.expected,
735 err.got,
736 fmt_span_suffix(&err.span)
737 )
738 }
739 }
740 }
741}
742
743fn fmt_span_suffix(span: &Option<Span>) -> String {
744 match span {
745 Some(s) => format!(" (at byte {}..{})", s.start, s.end),
746 None => String::new(),
747 }
748}
749
750impl std::error::Error for VmError {}
751
752#[cfg(test)]
753mod tests {
754 use super::*;
755
756 #[test]
760 fn all_categories_is_exhaustive() {
761 for category in &ErrorCategory::ALL {
762 match category {
763 ErrorCategory::Timeout
764 | ErrorCategory::Auth
765 | ErrorCategory::RateLimit
766 | ErrorCategory::Overloaded
767 | ErrorCategory::ServerError
768 | ErrorCategory::TransientNetwork
769 | ErrorCategory::ResourceBusy
770 | ErrorCategory::SchemaIncompatible
771 | ErrorCategory::SchemaValidation
772 | ErrorCategory::SchemaStreamAborted
773 | ErrorCategory::ToolError
774 | ErrorCategory::ToolRejected
775 | ErrorCategory::EgressBlocked
776 | ErrorCategory::Cancelled
777 | ErrorCategory::ChannelClosed
778 | ErrorCategory::NotFound
779 | ErrorCategory::CircuitOpen
780 | ErrorCategory::BudgetExceeded
781 | ErrorCategory::Internal
782 | ErrorCategory::Environment
783 | ErrorCategory::Generic => {}
784 }
785 }
786 assert_eq!(
787 ErrorCategory::ALL.len(),
788 21,
789 "a category was added or removed — update `ErrorCategory::ALL` and the \
790 `Error categories` table in docs/src/builtins.md"
791 );
792 }
793
794 #[test]
795 fn every_category_round_trips_through_parse() {
796 for category in &ErrorCategory::ALL {
797 assert_eq!(
798 &ErrorCategory::parse(category.as_str()),
799 category,
800 "`{}` does not round-trip — `parse` is missing an arm, so a \
801 host handing this category back to Harn silently gets \
802 `generic`",
803 category.as_str()
804 );
805 }
806 }
807
808 #[test]
813 fn every_category_is_documented_in_builtins_md() {
814 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../docs/src/builtins.md");
815 let doc =
816 std::fs::read_to_string(path).unwrap_or_else(|err| panic!("cannot read {path}: {err}"));
817 let table = doc
818 .split_once("### Error categories")
819 .unwrap_or_else(|| {
820 panic!("docs/src/builtins.md lost its `### Error categories` section")
821 })
822 .1;
823 let table = table.split_once("\n## ").map_or(table, |(head, _)| head);
824 for category in &ErrorCategory::ALL {
825 let row = format!("| `{}` |", category.as_str());
826 assert!(
827 table.contains(&row),
828 "`{}` is missing from the `Error categories` table in \
829 docs/src/builtins.md",
830 category.as_str()
831 );
832 }
833 }
834
835 #[test]
836 fn classifies_cancelled_messages() {
837 assert_eq!(
838 classify_error_message("Bridge: operation cancelled"),
839 ErrorCategory::Cancelled
840 );
841 assert_eq!(
842 classify_error_message("operation canceled by host"),
843 ErrorCategory::Cancelled
844 );
845 }
846
847 #[test]
848 fn classifies_undefined_builtin_as_internal() {
849 assert_eq!(
851 error_to_category(&VmError::UndefinedBuiltin("__host_agent_foo".into())),
852 ErrorCategory::Internal
853 );
854 assert_eq!(
856 error_to_category(&VmError::InvalidInstruction(200)),
857 ErrorCategory::Internal
858 );
859 assert_eq!(
862 error_to_category(&VmError::Runtime(
863 "Undefined builtin: __host_agent_foo (did you mean `bar`?)".into()
864 )),
865 ErrorCategory::Internal
866 );
867 assert_eq!(
868 classify_error_message("Undefined builtin: __host_agent_foo"),
869 ErrorCategory::Internal
870 );
871 assert!(!ErrorCategory::Internal.is_transient());
873 assert!(ErrorCategory::Internal.is_internal());
874 assert_eq!(ErrorCategory::Internal.as_str(), "internal");
876 assert_eq!(ErrorCategory::parse("internal"), ErrorCategory::Internal);
877 }
878
879 #[test]
880 fn classifies_openrouter_invalid_model_id_as_not_found() {
881 assert_eq!(
885 classify_error_message(
886 "openrouter API error: qwen/qwen3-coder-bogus is not a valid model ID"
887 ),
888 ErrorCategory::NotFound
889 );
890 assert_eq!(
891 classify_error_message("invalid model id supplied"),
892 ErrorCategory::NotFound
893 );
894 }
895
896 #[test]
897 fn categorized_error_lowers_to_structured_dict() {
898 let err = categorized_error(
902 "sandbox violation: /etc/passwd",
903 ErrorCategory::ToolRejected,
904 );
905 let VmValue::Dict(dict) = err.thrown_value() else {
906 panic!(
907 "categorized error must lower to a dict, got {:?}",
908 err.thrown_value()
909 );
910 };
911 assert_eq!(
912 dict.get("category").map(|v| v.display()).as_deref(),
913 Some("tool_rejected"),
914 );
915 assert_eq!(
916 dict.get("message").map(|v| v.display()).as_deref(),
917 Some("sandbox violation: /etc/passwd"),
918 );
919 let rendered = categorized_error("boom", ErrorCategory::Cancelled)
923 .thrown_value()
924 .display();
925 assert!(rendered.contains("cancelled"), "rendered dict: {rendered}");
926 assert!(rendered.contains("boom"), "rendered dict: {rendered}");
927 }
928
929 #[test]
930 fn thrown_value_passes_structured_thrown_through_unchanged() {
931 let original = VmValue::dict(std::collections::BTreeMap::from([(
934 "code".to_string(),
935 VmValue::Int(7),
936 )]));
937 let VmValue::Dict(dict) = VmError::Thrown(original).thrown_value() else {
938 panic!("thrown dict must pass through as a dict");
939 };
940 assert!(matches!(dict.get("code"), Some(VmValue::Int(7))));
941 }
942
943 #[test]
944 fn deadlock_renders_with_stable_code() {
945 let err = VmError::Deadlock(Box::new(DeadlockError::self_deadlock(
946 "mutex",
947 "__default__",
948 "re-entrant acquire",
949 )));
950 assert!(
951 err.to_string().starts_with("HARN-ORC-011"),
952 "deadlock Display must carry the stable code: {err}"
953 );
954 }
955
956 #[test]
957 fn deadlock_maps_to_generic_category() {
958 let err = VmError::Deadlock(Box::new(DeadlockError::self_deadlock(
959 "task",
960 "task_1",
961 "self-join",
962 )));
963 let category = error_to_category(&err);
964 assert_eq!(category, ErrorCategory::Generic);
965 assert!(
966 !category.is_transient(),
967 "a deadlock must not be treated as a retryable transient error"
968 );
969 }
970}