106 lines
4.3 KiB
Rust
106 lines
4.3 KiB
Rust
//! 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<crate::Error> {
|
|
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
|
|
}
|
|
}
|
|
}
|