fix(tui): retry transport-class body/decode failures for sub-agent requests

A scout died at 141 seconds because a DeepSeek response stream failed to
decode — the transient classifier's needle list covered headers, timeouts,
resets, and 5xx/429 wording but not body/decode failures, so the child
was classified Fatal on attempt one with zero retries and the parent had
to re-plan from a runtime event (morning-report issue #7).

Body-decode and truncated-response wording now classifies as transient:
'error decoding response body', the Chat API read/parse contexts, and
unexpected-EOF/incomplete-message phrasing. The existing bounded policy
applies unchanged — at most two same-prompt retries with 250ms backoff —
and auth-class failures stay fatal, pinned by the new classifier test.

Verified: subagent suite green; cargo fmt clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hmbown
2026-08-02 01:58:27 -07:00
parent bf04783951
commit 3e1eb5246c
2 changed files with 30 additions and 0 deletions
+9
View File
@@ -8535,6 +8535,15 @@ fn is_transient_subagent_provider_error(error: &anyhow::Error) -> bool {
"502",
"503",
"504",
// Body/decode failures are transport-class: the provider accepted the
// request and died mid-response (a DeepSeek stream decode error killed
// a 141s scout with zero retries — morning-report issue #7). One
// same-prompt retry is cheap next to re-planning the child.
"error decoding response body",
"failed to read chat api response body",
"failed to parse chat api json",
"unexpected end of file",
"incomplete message",
]
.iter()
.any(|needle| message.contains(needle))
+21
View File
@@ -5587,6 +5587,27 @@ fn transient_provider_classifier_matches_sse_header_timeout() {
assert!(is_transient_subagent_provider_error(&err));
}
#[test]
fn transient_provider_classifier_matches_body_decode_failures() {
// A provider that accepts the request and dies mid-response is a
// transport failure, not a fatal child error: one same-prompt retry is
// cheap next to re-planning 141 seconds of scout work (morning-report
// issue #7, DeepSeek stream decode).
let decode = anyhow::anyhow!("error decoding response body")
.context("Failed to read Chat API response body");
assert!(is_transient_subagent_provider_error(&decode));
let parse = anyhow::anyhow!("expected value at line 1 column 1")
.context("Failed to parse Chat API JSON");
assert!(is_transient_subagent_provider_error(&parse));
let auth = anyhow::anyhow!("401 unauthorized: invalid api key");
assert!(
!is_transient_subagent_provider_error(&auth),
"auth failures stay fatal"
);
}
#[test]
fn transient_provider_classifier_matches_structured_rate_limit() {
let err = anyhow::Error::new(crate::llm_client::LlmError::RateLimited {