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 SchemaIncompatible,
343 SchemaValidation,
345 SchemaStreamAborted,
352 ToolError,
354 ToolRejected,
356 EgressBlocked,
358 Cancelled,
360 ChannelClosed,
362 NotFound,
364 CircuitOpen,
366 BudgetExceeded,
368 Internal,
376 Environment,
385 Generic,
387}
388
389impl ErrorCategory {
390 pub const ALL: [Self; 21] = [
399 Self::Timeout,
400 Self::Auth,
401 Self::RateLimit,
402 Self::Overloaded,
403 Self::ServerError,
404 Self::TransientNetwork,
405 Self::ResourceBusy,
406 Self::SchemaIncompatible,
407 Self::SchemaValidation,
408 Self::SchemaStreamAborted,
409 Self::ToolError,
410 Self::ToolRejected,
411 Self::EgressBlocked,
412 Self::Cancelled,
413 Self::ChannelClosed,
414 Self::NotFound,
415 Self::CircuitOpen,
416 Self::BudgetExceeded,
417 Self::Internal,
418 Self::Environment,
419 Self::Generic,
420 ];
421
422 pub fn as_str(&self) -> &'static str {
423 match self {
424 ErrorCategory::Timeout => "timeout",
425 ErrorCategory::Auth => "auth",
426 ErrorCategory::RateLimit => "rate_limit",
427 ErrorCategory::Overloaded => "overloaded",
428 ErrorCategory::ServerError => "server_error",
429 ErrorCategory::TransientNetwork => "transient_network",
430 ErrorCategory::ResourceBusy => "resource_busy",
431 ErrorCategory::SchemaIncompatible => "schema_incompatible",
432 ErrorCategory::SchemaValidation => "schema_validation",
433 ErrorCategory::SchemaStreamAborted => "schema_stream_aborted",
434 ErrorCategory::ToolError => "tool_error",
435 ErrorCategory::ToolRejected => "tool_rejected",
436 ErrorCategory::EgressBlocked => "egress_blocked",
437 ErrorCategory::Cancelled => "cancelled",
438 ErrorCategory::ChannelClosed => "channel_closed",
439 ErrorCategory::NotFound => "not_found",
440 ErrorCategory::CircuitOpen => "circuit_open",
441 ErrorCategory::BudgetExceeded => "budget_exceeded",
442 ErrorCategory::Internal => "internal",
443 ErrorCategory::Environment => "environment",
444 ErrorCategory::Generic => "generic",
445 }
446 }
447
448 pub fn parse(s: &str) -> Self {
449 match s {
450 "timeout" => ErrorCategory::Timeout,
451 "auth" => ErrorCategory::Auth,
452 "rate_limit" => ErrorCategory::RateLimit,
453 "overloaded" => ErrorCategory::Overloaded,
454 "server_error" => ErrorCategory::ServerError,
455 "transient_network" => ErrorCategory::TransientNetwork,
456 "resource_busy" => ErrorCategory::ResourceBusy,
457 "schema_incompatible" => ErrorCategory::SchemaIncompatible,
458 "schema_validation" => ErrorCategory::SchemaValidation,
459 "schema_stream_aborted" => ErrorCategory::SchemaStreamAborted,
460 "tool_error" => ErrorCategory::ToolError,
461 "tool_rejected" => ErrorCategory::ToolRejected,
462 "egress_blocked" => ErrorCategory::EgressBlocked,
463 "cancelled" => ErrorCategory::Cancelled,
464 "channel_closed" => ErrorCategory::ChannelClosed,
465 "not_found" => ErrorCategory::NotFound,
466 "circuit_open" => ErrorCategory::CircuitOpen,
467 "budget_exceeded" => ErrorCategory::BudgetExceeded,
468 "internal" => ErrorCategory::Internal,
469 "environment" => ErrorCategory::Environment,
470 _ => ErrorCategory::Generic,
471 }
472 }
473
474 pub fn is_internal(&self) -> bool {
477 matches!(self, ErrorCategory::Internal)
478 }
479
480 pub fn is_transient(&self) -> bool {
484 matches!(
485 self,
486 ErrorCategory::Timeout
487 | ErrorCategory::RateLimit
488 | ErrorCategory::Overloaded
489 | ErrorCategory::ServerError
490 | ErrorCategory::TransientNetwork
491 | ErrorCategory::ResourceBusy
492 )
493 }
494}
495
496pub fn categorized_error(message: impl Into<String>, category: ErrorCategory) -> VmError {
498 VmError::CategorizedError {
499 message: message.into(),
500 category,
501 }
502}
503
504pub fn error_to_category(err: &VmError) -> ErrorCategory {
513 match err {
514 VmError::ExecutionDeadlineExceeded => ErrorCategory::Timeout,
515 VmError::ProcessExit(_) => ErrorCategory::Generic,
519 VmError::AbandonedExecution => ErrorCategory::Cancelled,
520 VmError::CategorizedError { category, .. } => category.clone(),
521 VmError::ProviderStreamFailure(failure) => failure.category(),
522 VmError::Thrown(VmValue::Dict(d)) => d
523 .get("category")
524 .map(|v| ErrorCategory::parse(&v.display()))
525 .unwrap_or(ErrorCategory::Generic),
526 VmError::Thrown(VmValue::String(s)) => classify_error_message(s),
527 VmError::Runtime(msg) => classify_error_message(msg),
528 VmError::UndefinedBuiltin(_) | VmError::InvalidInstruction(_) => ErrorCategory::Internal,
533 VmError::Deadlock(_) => ErrorCategory::Generic,
536 _ => ErrorCategory::Generic,
537 }
538}
539
540pub fn classify_error_message(msg: &str) -> ErrorCategory {
543 if let Some(cat) = classify_by_http_status(msg) {
545 return cat;
546 }
547 if msg.contains("Undefined builtin") {
552 return ErrorCategory::Internal;
553 }
554 let lower = msg.to_lowercase();
557 if lower.contains("cancelled") || lower.contains("canceled") {
558 return ErrorCategory::Cancelled;
559 }
560 if msg.contains("ChannelClosed") || lower.contains("channel closed") {
561 return ErrorCategory::ChannelClosed;
562 }
563 if msg.contains("Deadline exceeded") || msg.contains("context deadline exceeded") {
564 return ErrorCategory::Timeout;
565 }
566 if msg.contains("overloaded_error") {
567 return ErrorCategory::Overloaded;
569 }
570 if msg.contains("api_error") {
571 return ErrorCategory::ServerError;
573 }
574 if msg.contains("insufficient_quota") || msg.contains("billing_hard_limit_reached") {
575 return ErrorCategory::RateLimit;
577 }
578 if msg.contains("invalid_api_key") || msg.contains("authentication_error") {
579 return ErrorCategory::Auth;
580 }
581 if msg.contains("not_found_error") || msg.contains("model_not_found") {
582 return ErrorCategory::NotFound;
583 }
584 if lower.contains("is not a valid model id") || lower.contains("invalid model id") {
590 return ErrorCategory::NotFound;
591 }
592 if msg.contains("circuit_open") {
593 return ErrorCategory::CircuitOpen;
594 }
595 if lower.contains("connection reset")
597 || lower.contains("connection refused")
598 || lower.contains("connection closed")
599 || lower.contains("broken pipe")
600 || lower.contains("dns error")
601 || lower.contains("stream error")
602 || lower.contains("unexpected eof")
603 {
604 return ErrorCategory::TransientNetwork;
605 }
606 ErrorCategory::Generic
607}
608
609fn classify_by_http_status(msg: &str) -> Option<ErrorCategory> {
613 for code in extract_http_status_codes(msg) {
616 return Some(match code {
617 401 | 403 => ErrorCategory::Auth,
618 404 | 410 => ErrorCategory::NotFound,
619 408 | 504 | 522 | 524 => ErrorCategory::Timeout,
620 429 => ErrorCategory::RateLimit,
621 503 | 529 => ErrorCategory::Overloaded,
622 500 | 502 => ErrorCategory::ServerError,
623 _ => continue,
624 });
625 }
626 None
627}
628
629fn extract_http_status_codes(msg: &str) -> Vec<u16> {
631 let mut codes = Vec::new();
632 let bytes = msg.as_bytes();
633 for i in 0..bytes.len().saturating_sub(2) {
634 if bytes[i].is_ascii_digit()
636 && bytes[i + 1].is_ascii_digit()
637 && bytes[i + 2].is_ascii_digit()
638 {
639 let before_ok = i == 0 || !bytes[i - 1].is_ascii_digit();
641 let after_ok = i + 3 >= bytes.len() || !bytes[i + 3].is_ascii_digit();
642 if before_ok && after_ok {
643 if let Ok(code) = msg[i..i + 3].parse::<u16>() {
644 if (400..=599).contains(&code) {
645 codes.push(code);
646 }
647 }
648 }
649 }
650 }
651 codes
652}
653
654impl std::fmt::Display for VmError {
655 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
656 match self {
657 VmError::StackUnderflow => write!(f, "Stack underflow"),
658 VmError::StackOverflow => write!(f, "Stack overflow: too many nested calls"),
659 VmError::UndefinedVariable(n) => write!(f, "Undefined variable: {n}"),
660 VmError::UndefinedBuiltin(n) => write!(f, "Undefined builtin: {n}"),
661 VmError::ImmutableAssignment(n) => {
662 write!(f, "Cannot assign to immutable binding: {n}")
663 }
664 VmError::TypeError(msg) => write!(f, "Type error: {msg}"),
665 VmError::Runtime(msg) => write!(f, "Runtime error: {msg}"),
666 VmError::DivisionByZero => write!(f, "Division by zero"),
667 VmError::ExecutionDeadlineExceeded => write!(f, "Execution deadline exceeded"),
668 VmError::ProcessExit(code) => write!(f, "Process exit requested: {code}"),
669 VmError::AbandonedExecution => write!(
670 f,
671 "Execution future was abandoned; discard this VM and reset its exclusively owned execution context"
672 ),
673 VmError::Thrown(v) => write!(f, "Thrown: {}", v.display()),
674 VmError::CategorizedError { message, category } => {
675 write!(f, "Error [{}]: {}", category.as_str(), message)
676 }
677 VmError::ProviderStreamFailure(failure) => failure.fmt(f),
678 VmError::DaemonQueueFull {
679 daemon_id,
680 capacity,
681 } => write!(
682 f,
683 "Daemon queue full: daemon '{daemon_id}' reached its event_queue_capacity of {capacity}"
684 ),
685 VmError::Deadlock(err) => match err.diagnostic {
686 DeadlockDiagnostic::SelfDeadlock => write!(
687 f,
688 "{}: deadlock detected: {} ({} '{}') — this wait can never complete and would block forever",
689 err.diagnostic.code(),
690 err.detail,
691 err.kind,
692 err.key
693 ),
694 DeadlockDiagnostic::WaitForGraph => write!(
695 f,
696 "{}: wait-for deadlock detected: {} ({} '{}') — no active task can make progress",
697 err.diagnostic.code(),
698 err.detail,
699 err.kind,
700 err.key
701 ),
702 },
703 VmError::Return(_) => write!(f, "Return from function"),
704 VmError::InvalidInstruction(op) => write!(f, "Invalid instruction: 0x{op:02x}"),
705 VmError::ArityMismatch(err) => {
706 let arg_word = match err.expected {
707 ArityExpect::Exact(1) | ArityExpect::AtLeast(1) => "argument",
708 _ => "arguments",
709 };
710 write!(
711 f,
712 "Arity mismatch: '{}' expects {} {}, got {}{}",
713 err.callee,
714 err.expected,
715 arg_word,
716 err.got,
717 fmt_span_suffix(&err.span)
718 )
719 }
720 VmError::ArgTypeMismatch(err) => {
721 write!(
722 f,
723 "Type error: '{}' parameter `{}` expects {}, got {}{}",
724 err.callee,
725 err.param,
726 err.expected,
727 err.got,
728 fmt_span_suffix(&err.span)
729 )
730 }
731 }
732 }
733}
734
735fn fmt_span_suffix(span: &Option<Span>) -> String {
736 match span {
737 Some(s) => format!(" (at byte {}..{})", s.start, s.end),
738 None => String::new(),
739 }
740}
741
742impl std::error::Error for VmError {}
743
744#[cfg(test)]
745mod tests {
746 use super::*;
747
748 #[test]
752 fn all_categories_is_exhaustive() {
753 for category in &ErrorCategory::ALL {
754 match category {
755 ErrorCategory::Timeout
756 | ErrorCategory::Auth
757 | ErrorCategory::RateLimit
758 | ErrorCategory::Overloaded
759 | ErrorCategory::ServerError
760 | ErrorCategory::TransientNetwork
761 | ErrorCategory::ResourceBusy
762 | ErrorCategory::SchemaIncompatible
763 | ErrorCategory::SchemaValidation
764 | ErrorCategory::SchemaStreamAborted
765 | ErrorCategory::ToolError
766 | ErrorCategory::ToolRejected
767 | ErrorCategory::EgressBlocked
768 | ErrorCategory::Cancelled
769 | ErrorCategory::ChannelClosed
770 | ErrorCategory::NotFound
771 | ErrorCategory::CircuitOpen
772 | ErrorCategory::BudgetExceeded
773 | ErrorCategory::Internal
774 | ErrorCategory::Environment
775 | ErrorCategory::Generic => {}
776 }
777 }
778 assert_eq!(
779 ErrorCategory::ALL.len(),
780 21,
781 "a category was added or removed — update `ErrorCategory::ALL` and the \
782 `Error categories` table in docs/src/builtins.md"
783 );
784 }
785
786 #[test]
787 fn every_category_round_trips_through_parse() {
788 for category in &ErrorCategory::ALL {
789 assert_eq!(
790 &ErrorCategory::parse(category.as_str()),
791 category,
792 "`{}` does not round-trip — `parse` is missing an arm, so a \
793 host handing this category back to Harn silently gets \
794 `generic`",
795 category.as_str()
796 );
797 }
798 }
799
800 #[test]
805 fn every_category_is_documented_in_builtins_md() {
806 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../docs/src/builtins.md");
807 let doc =
808 std::fs::read_to_string(path).unwrap_or_else(|err| panic!("cannot read {path}: {err}"));
809 let table = doc
810 .split_once("### Error categories")
811 .unwrap_or_else(|| {
812 panic!("docs/src/builtins.md lost its `### Error categories` section")
813 })
814 .1;
815 let table = table.split_once("\n## ").map_or(table, |(head, _)| head);
816 for category in &ErrorCategory::ALL {
817 let row = format!("| `{}` |", category.as_str());
818 assert!(
819 table.contains(&row),
820 "`{}` is missing from the `Error categories` table in \
821 docs/src/builtins.md",
822 category.as_str()
823 );
824 }
825 }
826
827 #[test]
828 fn classifies_cancelled_messages() {
829 assert_eq!(
830 classify_error_message("Bridge: operation cancelled"),
831 ErrorCategory::Cancelled
832 );
833 assert_eq!(
834 classify_error_message("operation canceled by host"),
835 ErrorCategory::Cancelled
836 );
837 }
838
839 #[test]
840 fn classifies_undefined_builtin_as_internal() {
841 assert_eq!(
843 error_to_category(&VmError::UndefinedBuiltin("__host_agent_foo".into())),
844 ErrorCategory::Internal
845 );
846 assert_eq!(
848 error_to_category(&VmError::InvalidInstruction(200)),
849 ErrorCategory::Internal
850 );
851 assert_eq!(
854 error_to_category(&VmError::Runtime(
855 "Undefined builtin: __host_agent_foo (did you mean `bar`?)".into()
856 )),
857 ErrorCategory::Internal
858 );
859 assert_eq!(
860 classify_error_message("Undefined builtin: __host_agent_foo"),
861 ErrorCategory::Internal
862 );
863 assert!(!ErrorCategory::Internal.is_transient());
865 assert!(ErrorCategory::Internal.is_internal());
866 assert_eq!(ErrorCategory::Internal.as_str(), "internal");
868 assert_eq!(ErrorCategory::parse("internal"), ErrorCategory::Internal);
869 }
870
871 #[test]
872 fn classifies_openrouter_invalid_model_id_as_not_found() {
873 assert_eq!(
877 classify_error_message(
878 "openrouter API error: qwen/qwen3-coder-bogus is not a valid model ID"
879 ),
880 ErrorCategory::NotFound
881 );
882 assert_eq!(
883 classify_error_message("invalid model id supplied"),
884 ErrorCategory::NotFound
885 );
886 }
887
888 #[test]
889 fn categorized_error_lowers_to_structured_dict() {
890 let err = categorized_error(
894 "sandbox violation: /etc/passwd",
895 ErrorCategory::ToolRejected,
896 );
897 let VmValue::Dict(dict) = err.thrown_value() else {
898 panic!(
899 "categorized error must lower to a dict, got {:?}",
900 err.thrown_value()
901 );
902 };
903 assert_eq!(
904 dict.get("category").map(|v| v.display()).as_deref(),
905 Some("tool_rejected"),
906 );
907 assert_eq!(
908 dict.get("message").map(|v| v.display()).as_deref(),
909 Some("sandbox violation: /etc/passwd"),
910 );
911 let rendered = categorized_error("boom", ErrorCategory::Cancelled)
915 .thrown_value()
916 .display();
917 assert!(rendered.contains("cancelled"), "rendered dict: {rendered}");
918 assert!(rendered.contains("boom"), "rendered dict: {rendered}");
919 }
920
921 #[test]
922 fn thrown_value_passes_structured_thrown_through_unchanged() {
923 let original = VmValue::dict(std::collections::BTreeMap::from([(
926 "code".to_string(),
927 VmValue::Int(7),
928 )]));
929 let VmValue::Dict(dict) = VmError::Thrown(original).thrown_value() else {
930 panic!("thrown dict must pass through as a dict");
931 };
932 assert!(matches!(dict.get("code"), Some(VmValue::Int(7))));
933 }
934
935 #[test]
936 fn deadlock_renders_with_stable_code() {
937 let err = VmError::Deadlock(Box::new(DeadlockError::self_deadlock(
938 "mutex",
939 "__default__",
940 "re-entrant acquire",
941 )));
942 assert!(
943 err.to_string().starts_with("HARN-ORC-011"),
944 "deadlock Display must carry the stable code: {err}"
945 );
946 }
947
948 #[test]
949 fn deadlock_maps_to_generic_category() {
950 let err = VmError::Deadlock(Box::new(DeadlockError::self_deadlock(
951 "task",
952 "task_1",
953 "self-join",
954 )));
955 let category = error_to_category(&err);
956 assert_eq!(category, ErrorCategory::Generic);
957 assert!(
958 !category.is_transient(),
959 "a deadlock must not be treated as a retryable transient error"
960 );
961 }
962}