fix(hive-claude): don't scan model-authored assistant/user content for CLI failure markers

This commit is contained in:
müde 2026-07-06 00:15:42 +02:00
commit 50d801b6c9
2 changed files with 90 additions and 28 deletions

View file

@ -50,10 +50,42 @@ pub(crate) struct Sentinels {
}
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) {
/// Scan a CLI-authored line — stderr, or a non-JSON stdout line — for every
/// marker. These bytes are always claude-code's own output, never model
/// conversation, so all markers are trusted.
pub(crate) fn scan_cli_line(&self, line: &str) {
self.scan_failure_markers(line);
if RATE_LIMIT_MARKERS.iter().any(|m| line.contains(m)) {
self.rate_limited.store(true, Ordering::Relaxed);
}
}
/// Scan a parsed stdout JSON event. **Skips model-authored `assistant` /
/// `user` message events**, whose serialized content can quote any marker
/// verbatim (an agent discussing this very code, say) — a false positive
/// that would otherwise trip a needless compact/retry or a spurious
/// auth/session error. Every real signal here is emitted *instead of* a
/// model turn (the API rejected the prompt, the auth failed, or `--resume`
/// missed before any inference), so it can only appear on a control event
/// (`error` / `result` / `system`) or as raw non-JSON text — never inside
/// an assistant/user message. This holds whatever exact shape claude-code
/// uses for the message, so the gate can't suppress a genuine signal.
pub(crate) fn scan_stdout_json(&self, event: &serde_json::Value, raw: &str) {
let ty = event.get("type").and_then(|t| t.as_str());
if matches!(ty, Some("assistant" | "user")) {
return;
}
self.scan_failure_markers(raw);
// Rate-limit stays scoped to `error` events (unchanged): the only
// control event that carries a rate-limit marker.
if ty == Some("error") && RATE_LIMIT_MARKERS.iter().any(|m| raw.contains(m)) {
self.rate_limited.store(true, Ordering::Relaxed);
}
}
/// The prompt-too-long / auth-failed / session-not-found markers. Callers
/// gate *where* this runs (see `scan_cli_line` / `scan_stdout_json`).
fn scan_failure_markers(&self, line: &str) {
if line.contains(PROMPT_TOO_LONG_MARKER) {
self.prompt_too_long.store(true, Ordering::Relaxed);
}
@ -65,26 +97,6 @@ impl Sentinels {
}
}
/// Trust a rate-limit hit only on a JSON `error` event (so a model
/// *discussing* a rate limit in prose can't trigger it). The `type` gate
/// needs the parsed `event`; the marker match runs on `raw`, the original
/// line — the same bytes, so we don't re-serialize the value.
pub(crate) fn scan_stdout_json(&self, event: &serde_json::Value, raw: &str) {
if event.get("type").and_then(|t| t.as_str()) == Some("error")
&& RATE_LIMIT_MARKERS.iter().any(|m| raw.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
@ -105,3 +117,51 @@ impl Sentinels {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Error;
fn json(raw: &str) -> serde_json::Value {
serde_json::from_str(raw).unwrap()
}
#[test]
fn assistant_content_quoting_marker_is_ignored() {
// An agent discussing this code emits the marker verbatim in an
// assistant message — must NOT trip a sentinel.
let s = Sentinels::default();
let raw = r#"{"type":"assistant","message":{"content":[{"type":"text","text":"the CLI prints Prompt is too long on overflow"}]}}"#;
s.scan_stdout_json(&json(raw), raw);
assert!(s.soft_error().is_none());
}
#[test]
fn control_event_marker_is_detected() {
let s = Sentinels::default();
let raw = r#"{"type":"result","subtype":"error","error":"Prompt is too long"}"#;
s.scan_stdout_json(&json(raw), raw);
assert!(matches!(s.soft_error(), Some(Error::PromptTooLong)));
}
#[test]
fn raw_non_json_marker_is_detected() {
let s = Sentinels::default();
s.scan_cli_line("API Error: Prompt is too long");
assert!(matches!(s.soft_error(), Some(Error::PromptTooLong)));
}
#[test]
fn rate_limit_still_only_on_error_event() {
// A non-error control event mentioning the marker must not trip it.
let s = Sentinels::default();
let raw = r#"{"type":"result","summary":"we hit a rate_limit_error earlier"}"#;
s.scan_stdout_json(&json(raw), raw);
assert!(s.soft_error().is_none());
// A genuine error event does.
let raw2 = r#"{"type":"error","error":{"type":"rate_limit_error"}}"#;
s.scan_stdout_json(&json(raw2), raw2);
assert!(matches!(s.soft_error(), Some(Error::RateLimited)));
}
}

View file

@ -152,12 +152,14 @@ fn build_command(program: &str, config: &Config, attach: &Attach) -> Command {
async fn pump_stdout(stdout: ChildStdout, sink: &impl Sink, sentinels: &Sentinels) {
let mut lines = BufReader::new(stdout).lines();
while let Ok(Some(line)) = lines.next_line().await {
sentinels.scan_line(&line);
if let Ok(event) = serde_json::from_str::<serde_json::Value>(&line) {
// JSON stdout: classify with the model-content gate so an
// `assistant`/`user` message quoting a marker can't trip it.
sentinels.scan_stdout_json(&event, &line);
sink.on_event(&event);
} else {
sentinels.scan_rate_limit_text(&line);
// Non-JSON stdout is CLI text, not conversation — trust all markers.
sentinels.scan_cli_line(&line);
sink.on_stdout_line(&line);
}
}
@ -170,8 +172,8 @@ async fn pump_stderr(stderr: ChildStderr, sink: &impl Sink, sentinels: &Sentinel
let mut lines = BufReader::new(stderr).lines();
let mut tail: VecDeque<String> = VecDeque::with_capacity(STDERR_TAIL_LINES);
while let Ok(Some(line)) = lines.next_line().await {
sentinels.scan_line(&line);
sentinels.scan_rate_limit_text(&line);
// stderr is always CLI output — trust all markers.
sentinels.scan_cli_line(&line);
sink.on_stderr_line(&line);
if tail.len() >= STDERR_TAIL_LINES {
tail.pop_front();