//! Sentinel detection: mapping claude-code CLI output onto [`crate::Error`] //! variants. //! //! These marker strings are claude-code CLI knowledge, not app knowledge. They //! are empirically stable across CLI versions; if one drifts the run degrades //! gracefully (a clean turn, or a hard [`crate::Error::Exit`] on a non-zero //! exit) rather than misbehaving. use std::sync::atomic::{AtomicBool, Ordering}; /// Emitted when the prompt/context exceeds the model's window. const PROMPT_TOO_LONG_MARKER: &str = "Prompt is too long"; /// Substrings indicating the API refused for rate-limit / usage-cap / credit /// reasons. On stdout these are only trusted inside a JSON `error` event (see /// [`Sentinels::scan_stdout_json`] / [`Sentinels::scan_rate_limit_text`]) so a /// model *discussing* a rate limit in prose can't trigger a false positive. const RATE_LIMIT_MARKERS: [&str; 5] = [ "rate_limit_error", "overloaded_error", "Credit balance is too low", "Usage limit reached", "Request rate limit exceeded", ]; /// Substrings indicating the API rejected the request as unauthenticated (401) /// — an expired/revoked OAuth session. Sourced from claude-code's `api_retry` /// JSON events and its human-readable give-up line. const AUTH_FAIL_MARKERS: [&str; 3] = [ "\"error\":\"authentication_failed\"", "\"error_status\":401", "Failed to authenticate. API Error: 401", ]; /// Substrings indicating `--resume` could not resolve its target: no session /// with the given title, or no conversation with the given id. const SESSION_NOT_FOUND_MARKERS: [&str; 2] = [ "does not match any session title", "No conversation found with session ID", ]; /// Shared, lock-free sentinel flags accumulated while both output streams are /// pumped concurrently. Read once after the child exits. #[derive(Default)] pub(crate) struct Sentinels { prompt_too_long: AtomicBool, rate_limited: AtomicBool, auth_failed: AtomicBool, session_not_found: AtomicBool, } impl Sentinels { /// Scan a raw line (stdout or stderr) for the always-on markers: /// prompt-too-long, auth-failed, session-not-found. Rate-limit is handled /// separately because on stdout it must only fire on JSON `error` events. pub(crate) fn scan_line(&self, line: &str) { if line.contains(PROMPT_TOO_LONG_MARKER) { self.prompt_too_long.store(true, Ordering::Relaxed); } if AUTH_FAIL_MARKERS.iter().any(|m| line.contains(m)) { self.auth_failed.store(true, Ordering::Relaxed); } if SESSION_NOT_FOUND_MARKERS.iter().any(|m| line.contains(m)) { self.session_not_found.store(true, Ordering::Relaxed); } } /// Trust a rate-limit hit on a JSON `error` event's serialized form. pub(crate) fn scan_stdout_json(&self, event: &serde_json::Value) { if event.get("type").and_then(|t| t.as_str()) == Some("error") && RATE_LIMIT_MARKERS .iter() .any(|m| event.to_string().contains(m)) { self.rate_limited.store(true, Ordering::Relaxed); } } /// Trust a rate-limit hit on raw text (non-JSON stdout, or any stderr) — /// these are CLI messages, not conversation content. pub(crate) fn scan_rate_limit_text(&self, line: &str) { if RATE_LIMIT_MARKERS.iter().any(|m| line.contains(m)) { self.rate_limited.store(true, Ordering::Relaxed); } } /// The recognized-sentinel error, if any fired — `None` means no sentinel /// (so the run either completed or failed hard on its exit code). The /// sentinels keep a fixed priority (too-long > rate > auth); a /// session-not-found can only arise on a resume that made no model call, /// so it never coincides with the others. pub(crate) fn soft_error(&self) -> Option { use crate::Error; if self.prompt_too_long.load(Ordering::Relaxed) { Some(Error::PromptTooLong) } else if self.rate_limited.load(Ordering::Relaxed) { Some(Error::RateLimited) } else if self.auth_failed.load(Ordering::Relaxed) { Some(Error::AuthFailed) } else if self.session_not_found.load(Ordering::Relaxed) { Some(Error::SessionNotFound) } else { None } } }