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 Thrown(VmValue),
124 CategorizedError {
126 message: String,
127 category: ErrorCategory,
128 },
129 ProviderStreamFailure(Box<ProviderStreamFailure>),
134 DaemonQueueFull {
135 daemon_id: String,
136 capacity: usize,
137 },
138 Deadlock(Box<DeadlockError>),
145 Return(VmValue),
146 InvalidInstruction(u8),
147 ArityMismatch(Box<ArityMismatchError>),
151 ArgTypeMismatch(Box<ArgTypeMismatchError>),
157}
158
159impl VmError {
160 pub fn is_uncatchable_control_flow(&self) -> bool {
163 matches!(self, Self::ExecutionDeadlineExceeded | Self::ProcessExit(_))
164 }
165
166 pub fn process_exit_code(&self) -> Option<i32> {
169 match self {
170 Self::ProcessExit(code) => Some(*code),
171 _ => None,
172 }
173 }
174
175 pub fn thrown_value(&self) -> VmValue {
196 match self {
197 VmError::Thrown(v) => v.clone(),
198 VmError::CategorizedError { message, category } => {
199 let mut dict = std::collections::BTreeMap::new();
200 dict.put_str("category", category.as_str());
201 dict.put_str("message", message);
202 VmValue::dict(dict)
203 }
204 VmError::ProviderStreamFailure(failure) => failure.thrown_value(),
205 other => VmValue::String(arcstr::ArcStr::from(other.to_string())),
206 }
207 }
208
209 pub fn provider_stream_failure(&self) -> Option<&ProviderStreamFailure> {
210 match self {
211 Self::ProviderStreamFailure(failure) => Some(failure),
212 _ => None,
213 }
214 }
215}
216
217#[derive(Debug, Clone, Copy, PartialEq, Eq)]
218pub enum ProviderStreamPhase {
219 AwaitingFirstChunk,
220 Streaming,
221}
222
223impl ProviderStreamPhase {
224 pub fn as_str(self) -> &'static str {
225 match self {
226 Self::AwaitingFirstChunk => "awaiting_first_chunk",
227 Self::Streaming => "streaming",
228 }
229 }
230}
231
232#[derive(Debug, Clone, Copy, PartialEq, Eq)]
233pub enum ProviderStreamFailureReason {
234 Read,
235 PrematureEof,
236 Deadline,
237}
238
239impl ProviderStreamFailureReason {
240 pub fn as_str(self) -> &'static str {
241 match self {
242 Self::Read => "read",
243 Self::PrematureEof => "premature_eof",
244 Self::Deadline => "deadline",
245 }
246 }
247}
248
249#[derive(Debug, Clone, Copy, PartialEq, Eq)]
250pub enum ProviderStreamDeadline {
251 Total,
252 FirstChunk,
253 Idle,
254}
255
256impl ProviderStreamDeadline {
257 pub fn as_str(self) -> &'static str {
258 match self {
259 Self::Total => "total",
260 Self::FirstChunk => "first_chunk",
261 Self::Idle => "idle",
262 }
263 }
264}
265
266#[derive(Debug, Clone, PartialEq, Eq)]
267pub struct ProviderStreamFailure {
268 pub provider: String,
269 pub phase: ProviderStreamPhase,
270 pub reason: ProviderStreamFailureReason,
271 pub deadline: Option<ProviderStreamDeadline>,
272 pub partial: bool,
273 pub detail: String,
274}
275
276impl ProviderStreamFailure {
277 pub fn category(&self) -> ErrorCategory {
278 if self.deadline.is_some() {
279 ErrorCategory::Timeout
280 } else {
281 ErrorCategory::TransientNetwork
282 }
283 }
284
285 fn thrown_value(&self) -> VmValue {
286 let mut dict = std::collections::BTreeMap::new();
287 dict.put_str("category", self.category().as_str());
288 dict.put_str("message", self.to_string());
289 dict.put_str("source", "provider_stream");
290 dict.put_str("phase", self.phase.as_str());
291 dict.put_str("reason", self.reason.as_str());
292 dict.insert(
293 "deadline".to_string(),
294 self.deadline
295 .map(|deadline| VmValue::String(arcstr::ArcStr::from(deadline.as_str())))
296 .unwrap_or(VmValue::Nil),
297 );
298 dict.insert("partial".to_string(), VmValue::Bool(self.partial));
299 VmValue::dict(dict)
300 }
301}
302
303impl std::fmt::Display for ProviderStreamFailure {
304 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
305 write!(
306 f,
307 "{} provider stream failure (phase={}, reason={}",
308 self.provider,
309 self.phase.as_str(),
310 self.reason.as_str()
311 )?;
312 if let Some(deadline) = self.deadline {
313 write!(f, ", deadline={}", deadline.as_str())?;
314 }
315 write!(f, ", partial={}): {}", self.partial, self.detail)
316 }
317}
318
319#[derive(Debug, Clone, PartialEq, Eq)]
321pub enum ErrorCategory {
322 Timeout,
324 Auth,
326 RateLimit,
328 Overloaded,
332 ServerError,
334 TransientNetwork,
337 ResourceBusy,
340 SchemaValidation,
342 SchemaStreamAborted,
349 ToolError,
351 ToolRejected,
353 EgressBlocked,
355 Cancelled,
357 ChannelClosed,
359 NotFound,
361 CircuitOpen,
363 BudgetExceeded,
365 Internal,
373 Environment,
382 Generic,
384}
385
386impl ErrorCategory {
387 pub const ALL: [Self; 20] = [
396 Self::Timeout,
397 Self::Auth,
398 Self::RateLimit,
399 Self::Overloaded,
400 Self::ServerError,
401 Self::TransientNetwork,
402 Self::ResourceBusy,
403 Self::SchemaValidation,
404 Self::SchemaStreamAborted,
405 Self::ToolError,
406 Self::ToolRejected,
407 Self::EgressBlocked,
408 Self::Cancelled,
409 Self::ChannelClosed,
410 Self::NotFound,
411 Self::CircuitOpen,
412 Self::BudgetExceeded,
413 Self::Internal,
414 Self::Environment,
415 Self::Generic,
416 ];
417
418 pub fn as_str(&self) -> &'static str {
419 match self {
420 ErrorCategory::Timeout => "timeout",
421 ErrorCategory::Auth => "auth",
422 ErrorCategory::RateLimit => "rate_limit",
423 ErrorCategory::Overloaded => "overloaded",
424 ErrorCategory::ServerError => "server_error",
425 ErrorCategory::TransientNetwork => "transient_network",
426 ErrorCategory::ResourceBusy => "resource_busy",
427 ErrorCategory::SchemaValidation => "schema_validation",
428 ErrorCategory::SchemaStreamAborted => "schema_stream_aborted",
429 ErrorCategory::ToolError => "tool_error",
430 ErrorCategory::ToolRejected => "tool_rejected",
431 ErrorCategory::EgressBlocked => "egress_blocked",
432 ErrorCategory::Cancelled => "cancelled",
433 ErrorCategory::ChannelClosed => "channel_closed",
434 ErrorCategory::NotFound => "not_found",
435 ErrorCategory::CircuitOpen => "circuit_open",
436 ErrorCategory::BudgetExceeded => "budget_exceeded",
437 ErrorCategory::Internal => "internal",
438 ErrorCategory::Environment => "environment",
439 ErrorCategory::Generic => "generic",
440 }
441 }
442
443 pub fn parse(s: &str) -> Self {
444 match s {
445 "timeout" => ErrorCategory::Timeout,
446 "auth" => ErrorCategory::Auth,
447 "rate_limit" => ErrorCategory::RateLimit,
448 "overloaded" => ErrorCategory::Overloaded,
449 "server_error" => ErrorCategory::ServerError,
450 "transient_network" => ErrorCategory::TransientNetwork,
451 "resource_busy" => ErrorCategory::ResourceBusy,
452 "schema_validation" => ErrorCategory::SchemaValidation,
453 "schema_stream_aborted" => ErrorCategory::SchemaStreamAborted,
454 "tool_error" => ErrorCategory::ToolError,
455 "tool_rejected" => ErrorCategory::ToolRejected,
456 "egress_blocked" => ErrorCategory::EgressBlocked,
457 "cancelled" => ErrorCategory::Cancelled,
458 "channel_closed" => ErrorCategory::ChannelClosed,
459 "not_found" => ErrorCategory::NotFound,
460 "circuit_open" => ErrorCategory::CircuitOpen,
461 "budget_exceeded" => ErrorCategory::BudgetExceeded,
462 "internal" => ErrorCategory::Internal,
463 "environment" => ErrorCategory::Environment,
464 _ => ErrorCategory::Generic,
465 }
466 }
467
468 pub fn is_internal(&self) -> bool {
471 matches!(self, ErrorCategory::Internal)
472 }
473
474 pub fn is_transient(&self) -> bool {
478 matches!(
479 self,
480 ErrorCategory::Timeout
481 | ErrorCategory::RateLimit
482 | ErrorCategory::Overloaded
483 | ErrorCategory::ServerError
484 | ErrorCategory::TransientNetwork
485 | ErrorCategory::ResourceBusy
486 )
487 }
488}
489
490pub fn categorized_error(message: impl Into<String>, category: ErrorCategory) -> VmError {
492 VmError::CategorizedError {
493 message: message.into(),
494 category,
495 }
496}
497
498pub fn error_to_category(err: &VmError) -> ErrorCategory {
507 match err {
508 VmError::ExecutionDeadlineExceeded => ErrorCategory::Timeout,
509 VmError::ProcessExit(_) => ErrorCategory::Generic,
513 VmError::AbandonedExecution => ErrorCategory::Cancelled,
514 VmError::CategorizedError { category, .. } => category.clone(),
515 VmError::ProviderStreamFailure(failure) => failure.category(),
516 VmError::Thrown(VmValue::Dict(d)) => d
517 .get("category")
518 .map(|v| ErrorCategory::parse(&v.display()))
519 .unwrap_or(ErrorCategory::Generic),
520 VmError::Thrown(VmValue::String(s)) => classify_error_message(s),
521 VmError::Runtime(msg) => classify_error_message(msg),
522 VmError::UndefinedBuiltin(_) | VmError::InvalidInstruction(_) => ErrorCategory::Internal,
527 VmError::Deadlock(_) => ErrorCategory::Generic,
530 _ => ErrorCategory::Generic,
531 }
532}
533
534pub fn classify_error_message(msg: &str) -> ErrorCategory {
537 if let Some(cat) = classify_by_http_status(msg) {
539 return cat;
540 }
541 if msg.contains("Undefined builtin") {
546 return ErrorCategory::Internal;
547 }
548 let lower = msg.to_lowercase();
551 if lower.contains("cancelled") || lower.contains("canceled") {
552 return ErrorCategory::Cancelled;
553 }
554 if msg.contains("ChannelClosed") || lower.contains("channel closed") {
555 return ErrorCategory::ChannelClosed;
556 }
557 if msg.contains("Deadline exceeded") || msg.contains("context deadline exceeded") {
558 return ErrorCategory::Timeout;
559 }
560 if msg.contains("overloaded_error") {
561 return ErrorCategory::Overloaded;
563 }
564 if msg.contains("api_error") {
565 return ErrorCategory::ServerError;
567 }
568 if msg.contains("insufficient_quota") || msg.contains("billing_hard_limit_reached") {
569 return ErrorCategory::RateLimit;
571 }
572 if msg.contains("invalid_api_key") || msg.contains("authentication_error") {
573 return ErrorCategory::Auth;
574 }
575 if msg.contains("not_found_error") || msg.contains("model_not_found") {
576 return ErrorCategory::NotFound;
577 }
578 if lower.contains("is not a valid model id") || lower.contains("invalid model id") {
584 return ErrorCategory::NotFound;
585 }
586 if msg.contains("circuit_open") {
587 return ErrorCategory::CircuitOpen;
588 }
589 if lower.contains("connection reset")
591 || lower.contains("connection refused")
592 || lower.contains("connection closed")
593 || lower.contains("broken pipe")
594 || lower.contains("dns error")
595 || lower.contains("stream error")
596 || lower.contains("unexpected eof")
597 {
598 return ErrorCategory::TransientNetwork;
599 }
600 ErrorCategory::Generic
601}
602
603fn classify_by_http_status(msg: &str) -> Option<ErrorCategory> {
607 for code in extract_http_status_codes(msg) {
610 return Some(match code {
611 401 | 403 => ErrorCategory::Auth,
612 404 | 410 => ErrorCategory::NotFound,
613 408 | 504 | 522 | 524 => ErrorCategory::Timeout,
614 429 => ErrorCategory::RateLimit,
615 503 | 529 => ErrorCategory::Overloaded,
616 500 | 502 => ErrorCategory::ServerError,
617 _ => continue,
618 });
619 }
620 None
621}
622
623fn extract_http_status_codes(msg: &str) -> Vec<u16> {
625 let mut codes = Vec::new();
626 let bytes = msg.as_bytes();
627 for i in 0..bytes.len().saturating_sub(2) {
628 if bytes[i].is_ascii_digit()
630 && bytes[i + 1].is_ascii_digit()
631 && bytes[i + 2].is_ascii_digit()
632 {
633 let before_ok = i == 0 || !bytes[i - 1].is_ascii_digit();
635 let after_ok = i + 3 >= bytes.len() || !bytes[i + 3].is_ascii_digit();
636 if before_ok && after_ok {
637 if let Ok(code) = msg[i..i + 3].parse::<u16>() {
638 if (400..=599).contains(&code) {
639 codes.push(code);
640 }
641 }
642 }
643 }
644 }
645 codes
646}
647
648impl std::fmt::Display for VmError {
649 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
650 match self {
651 VmError::StackUnderflow => write!(f, "Stack underflow"),
652 VmError::StackOverflow => write!(f, "Stack overflow: too many nested calls"),
653 VmError::UndefinedVariable(n) => write!(f, "Undefined variable: {n}"),
654 VmError::UndefinedBuiltin(n) => write!(f, "Undefined builtin: {n}"),
655 VmError::ImmutableAssignment(n) => {
656 write!(f, "Cannot assign to immutable binding: {n}")
657 }
658 VmError::TypeError(msg) => write!(f, "Type error: {msg}"),
659 VmError::Runtime(msg) => write!(f, "Runtime error: {msg}"),
660 VmError::DivisionByZero => write!(f, "Division by zero"),
661 VmError::ExecutionDeadlineExceeded => write!(f, "Execution deadline exceeded"),
662 VmError::ProcessExit(code) => write!(f, "Process exit requested: {code}"),
663 VmError::AbandonedExecution => write!(
664 f,
665 "Execution future was abandoned; discard this VM and reset its exclusively owned execution context"
666 ),
667 VmError::Thrown(v) => write!(f, "Thrown: {}", v.display()),
668 VmError::CategorizedError { message, category } => {
669 write!(f, "Error [{}]: {}", category.as_str(), message)
670 }
671 VmError::ProviderStreamFailure(failure) => failure.fmt(f),
672 VmError::DaemonQueueFull {
673 daemon_id,
674 capacity,
675 } => write!(
676 f,
677 "Daemon queue full: daemon '{daemon_id}' reached its event_queue_capacity of {capacity}"
678 ),
679 VmError::Deadlock(err) => match err.diagnostic {
680 DeadlockDiagnostic::SelfDeadlock => write!(
681 f,
682 "{}: deadlock detected: {} ({} '{}') — this wait can never complete and would block forever",
683 err.diagnostic.code(),
684 err.detail,
685 err.kind,
686 err.key
687 ),
688 DeadlockDiagnostic::WaitForGraph => write!(
689 f,
690 "{}: wait-for deadlock detected: {} ({} '{}') — no active task can make progress",
691 err.diagnostic.code(),
692 err.detail,
693 err.kind,
694 err.key
695 ),
696 },
697 VmError::Return(_) => write!(f, "Return from function"),
698 VmError::InvalidInstruction(op) => write!(f, "Invalid instruction: 0x{op:02x}"),
699 VmError::ArityMismatch(err) => {
700 let arg_word = match err.expected {
701 ArityExpect::Exact(1) | ArityExpect::AtLeast(1) => "argument",
702 _ => "arguments",
703 };
704 write!(
705 f,
706 "Arity mismatch: '{}' expects {} {}, got {}{}",
707 err.callee,
708 err.expected,
709 arg_word,
710 err.got,
711 fmt_span_suffix(&err.span)
712 )
713 }
714 VmError::ArgTypeMismatch(err) => {
715 write!(
716 f,
717 "Type error: '{}' parameter `{}` expects {}, got {}{}",
718 err.callee,
719 err.param,
720 err.expected,
721 err.got,
722 fmt_span_suffix(&err.span)
723 )
724 }
725 }
726 }
727}
728
729fn fmt_span_suffix(span: &Option<Span>) -> String {
730 match span {
731 Some(s) => format!(" (at byte {}..{})", s.start, s.end),
732 None => String::new(),
733 }
734}
735
736impl std::error::Error for VmError {}
737
738#[cfg(test)]
739mod tests {
740 use super::*;
741
742 #[test]
746 fn all_categories_is_exhaustive() {
747 for category in &ErrorCategory::ALL {
748 match category {
749 ErrorCategory::Timeout
750 | ErrorCategory::Auth
751 | ErrorCategory::RateLimit
752 | ErrorCategory::Overloaded
753 | ErrorCategory::ServerError
754 | ErrorCategory::TransientNetwork
755 | ErrorCategory::ResourceBusy
756 | ErrorCategory::SchemaValidation
757 | ErrorCategory::SchemaStreamAborted
758 | ErrorCategory::ToolError
759 | ErrorCategory::ToolRejected
760 | ErrorCategory::EgressBlocked
761 | ErrorCategory::Cancelled
762 | ErrorCategory::ChannelClosed
763 | ErrorCategory::NotFound
764 | ErrorCategory::CircuitOpen
765 | ErrorCategory::BudgetExceeded
766 | ErrorCategory::Internal
767 | ErrorCategory::Environment
768 | ErrorCategory::Generic => {}
769 }
770 }
771 assert_eq!(
772 ErrorCategory::ALL.len(),
773 20,
774 "a category was added or removed — update `ErrorCategory::ALL` and the \
775 `Error categories` table in docs/src/builtins.md"
776 );
777 }
778
779 #[test]
780 fn every_category_round_trips_through_parse() {
781 for category in &ErrorCategory::ALL {
782 assert_eq!(
783 &ErrorCategory::parse(category.as_str()),
784 category,
785 "`{}` does not round-trip — `parse` is missing an arm, so a \
786 host handing this category back to Harn silently gets \
787 `generic`",
788 category.as_str()
789 );
790 }
791 }
792
793 #[test]
798 fn every_category_is_documented_in_builtins_md() {
799 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../docs/src/builtins.md");
800 let doc =
801 std::fs::read_to_string(path).unwrap_or_else(|err| panic!("cannot read {path}: {err}"));
802 let table = doc
803 .split_once("### Error categories")
804 .unwrap_or_else(|| {
805 panic!("docs/src/builtins.md lost its `### Error categories` section")
806 })
807 .1;
808 let table = table.split_once("\n## ").map_or(table, |(head, _)| head);
809 for category in &ErrorCategory::ALL {
810 let row = format!("| `{}` |", category.as_str());
811 assert!(
812 table.contains(&row),
813 "`{}` is missing from the `Error categories` table in \
814 docs/src/builtins.md",
815 category.as_str()
816 );
817 }
818 }
819
820 #[test]
821 fn classifies_cancelled_messages() {
822 assert_eq!(
823 classify_error_message("Bridge: operation cancelled"),
824 ErrorCategory::Cancelled
825 );
826 assert_eq!(
827 classify_error_message("operation canceled by host"),
828 ErrorCategory::Cancelled
829 );
830 }
831
832 #[test]
833 fn classifies_undefined_builtin_as_internal() {
834 assert_eq!(
836 error_to_category(&VmError::UndefinedBuiltin("__host_agent_foo".into())),
837 ErrorCategory::Internal
838 );
839 assert_eq!(
841 error_to_category(&VmError::InvalidInstruction(200)),
842 ErrorCategory::Internal
843 );
844 assert_eq!(
847 error_to_category(&VmError::Runtime(
848 "Undefined builtin: __host_agent_foo (did you mean `bar`?)".into()
849 )),
850 ErrorCategory::Internal
851 );
852 assert_eq!(
853 classify_error_message("Undefined builtin: __host_agent_foo"),
854 ErrorCategory::Internal
855 );
856 assert!(!ErrorCategory::Internal.is_transient());
858 assert!(ErrorCategory::Internal.is_internal());
859 assert_eq!(ErrorCategory::Internal.as_str(), "internal");
861 assert_eq!(ErrorCategory::parse("internal"), ErrorCategory::Internal);
862 }
863
864 #[test]
865 fn classifies_openrouter_invalid_model_id_as_not_found() {
866 assert_eq!(
870 classify_error_message(
871 "openrouter API error: qwen/qwen3-coder-bogus is not a valid model ID"
872 ),
873 ErrorCategory::NotFound
874 );
875 assert_eq!(
876 classify_error_message("invalid model id supplied"),
877 ErrorCategory::NotFound
878 );
879 }
880
881 #[test]
882 fn categorized_error_lowers_to_structured_dict() {
883 let err = categorized_error(
887 "sandbox violation: /etc/passwd",
888 ErrorCategory::ToolRejected,
889 );
890 let VmValue::Dict(dict) = err.thrown_value() else {
891 panic!(
892 "categorized error must lower to a dict, got {:?}",
893 err.thrown_value()
894 );
895 };
896 assert_eq!(
897 dict.get("category").map(|v| v.display()).as_deref(),
898 Some("tool_rejected"),
899 );
900 assert_eq!(
901 dict.get("message").map(|v| v.display()).as_deref(),
902 Some("sandbox violation: /etc/passwd"),
903 );
904 let rendered = categorized_error("boom", ErrorCategory::Cancelled)
908 .thrown_value()
909 .display();
910 assert!(rendered.contains("cancelled"), "rendered dict: {rendered}");
911 assert!(rendered.contains("boom"), "rendered dict: {rendered}");
912 }
913
914 #[test]
915 fn thrown_value_passes_structured_thrown_through_unchanged() {
916 let original = VmValue::dict(std::collections::BTreeMap::from([(
919 "code".to_string(),
920 VmValue::Int(7),
921 )]));
922 let VmValue::Dict(dict) = VmError::Thrown(original).thrown_value() else {
923 panic!("thrown dict must pass through as a dict");
924 };
925 assert!(matches!(dict.get("code"), Some(VmValue::Int(7))));
926 }
927
928 #[test]
929 fn deadlock_renders_with_stable_code() {
930 let err = VmError::Deadlock(Box::new(DeadlockError::self_deadlock(
931 "mutex",
932 "__default__",
933 "re-entrant acquire",
934 )));
935 assert!(
936 err.to_string().starts_with("HARN-ORC-011"),
937 "deadlock Display must carry the stable code: {err}"
938 );
939 }
940
941 #[test]
942 fn deadlock_maps_to_generic_category() {
943 let err = VmError::Deadlock(Box::new(DeadlockError::self_deadlock(
944 "task",
945 "task_1",
946 "self-join",
947 )));
948 let category = error_to_category(&err);
949 assert_eq!(category, ErrorCategory::Generic);
950 assert!(
951 !category.is_transient(),
952 "a deadlock must not be treated as a retryable transient error"
953 );
954 }
955}