fix(core): retry on failed as well as timeout; only auth stops the run (#104)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yaowei Zheng
2026-07-29 16:57:05 +08:00
committed by GitHub
parent 8dc560eedd
commit 5b22ad06da
30 changed files with 603 additions and 174 deletions
@@ -8,14 +8,16 @@
*
* - `expected`: anticipated by the system, has a defined handling path, part of normal
* operation, no human needed — HTTP business errors (`HttpError`, mostly 4xx); LLM
* `timeout` / `malformed` (the engine already reconnects and retries); tool execution
* `failed` / `timeout` (the error is fed back to the model, and the Agent adjusts on
* its own).
* `timeout` / `malformed`, and an LLM `failed` the engine went on to retry (the engine
* reconnects on all three, so a request the ladder carried cost the user nothing); tool
* execution `failed` / `timeout` (the error is fed back to the model, and the Agent
* adjusts on its own).
* - `unexpected`: shouldn't happen, usually a bug or a config/environment fault,
* **needs a human** — internal errors converged to 500; process crashes; runtime
* errors escaping from background tasks (Session drive / usage persistence / title
* generation / subagent registration); LLM `failed` (not retryable: auth failure,
* invalid params, etc.).
* generation / subagent registration); LLM `auth` (the credential was rejected and only
* a human can replace it) and an LLM `failed` the retries did not recover (the run ended
* on it and the user lost the turn).
* - User-initiated actions **are not errors** and are never recorded: request/tool
* `aborted` (user clicked "stop", or denied a tool).
*
@@ -9,8 +9,19 @@
* (its state wraps up accordingly, see close).
*
* LLM (source = `llm`): reads the status of `request_end` —
* - `failed` / `auth` → unexpected (not retryable: credentials rejected, invalid params,
* etc., needs a human; both share the llm_failed code — no separate taxonomy);
* - `auth` → unexpected (`llm_auth`): the credential was rejected, the engine never retries
* it, and only a human holding the key can fix it. Its own code rather than the failed
* bucket, because dedup is `(source, code, Project)` over a short window: sharing a code
* with a class that now fires on every recovered blip would let a real credential failure
* be swallowed as a duplicate of something already handled.
* - `failed` → depends on whether the retry ladder carried it. The engine reconnects on
* `failed` too, so the same status covers both "a gateway hiccup nobody needed to know
* about" and "the run died on it":
* - another attempt followed (a `request_begin` resolved the pending record) → expected
* (`llm_failed_retried`), same footing as timeout/malformed: recorded for the record,
* not raised at an operator;
* - nothing followed but the end of the run (an `abort`, or close) → unexpected
* (`llm_failed`): the retries did not recover it, the user lost the turn, needs a human.
* - `timeout` / `malformed` → expected (the engine already reconnects and retries, part
* of normal operation);
* - `aborted` / `completed` are not recorded (the former is a user-initiated interrupt,
@@ -24,7 +35,8 @@
* - Immediately followed by `abort` → use its reason as the message (the real reason);
* - Immediately followed by `request_begin` (the engine is retrying — no abort will ever
* arrive) → use the staged request_end's own `message` (the real detail, e.g. a quota
* code), falling back to the generic status text when it carried none;
* code), falling back to the generic status text when it carried none. This boundary is
* also what tells a survived `failed` from an exhausted one (see the classification above);
* - Still unresolved when the run ends → close persists it the same way.
* Exception: when reason is a user-interrupt message (`aborted …`), it's not trusted —
* "the user clicked stop during backoff" isn't the reason for this timeout, so the
@@ -78,12 +90,31 @@ export const ORIGIN_CTX_MAX = 200;
/** Recorded LLM failure states (`aborted` / `completed` are not errors and aren't included here). */
type LlmFailure = "failed" | "timeout" | "malformed" | "auth";
/** LLM failure state → error code, classification, and fallback message (used when the abort reason isn't available). */
const LLM_FAILURES: Record<LlmFailure, { code: string; kind: ErrorKind; text: string }> = {
failed: { code: "llm_failed", kind: "unexpected", text: "LLM request failed (not retryable)." },
// Credentials rejection shares the failed bucket (no new taxonomy): same code/kind, the
// real reason arrives via the abort reason or the event's own message as usual.
auth: { code: "llm_failed", kind: "unexpected", text: "LLM request failed (not retryable)." },
/** Error code, classification, and fallback message (the last used when no abort reason is available). */
interface FailureSpec {
code: string;
kind: ErrorKind;
text: string;
}
/** LLM failure state → its spec, for a failure the retry ladder did NOT carry (see the file header). */
const LLM_FAILURES: Record<LlmFailure, FailureSpec> = {
// Reached here only when no further attempt followed: the ladder ran out (or the run ended
// on it), so the user lost the turn and someone should look. A `failed` that was retried
// takes LLM_FAILED_RETRIED instead.
failed: {
code: "llm_failed",
kind: "unexpected",
text: "LLM request failed and the retries did not recover it.",
},
// Its own code, not the failed bucket: dedup is `(source, code, Project)` over a short
// window, and `llm_failed_retried` can now fire on any recovered blip — sharing a bucket
// would let a genuine credential failure be dropped as a duplicate of one.
auth: {
code: "llm_auth",
kind: "unexpected",
text: "LLM request rejected: the provider did not accept the credentials.",
},
timeout: {
code: "llm_timeout",
kind: "expected",
@@ -96,6 +127,18 @@ const LLM_FAILURES: Record<LlmFailure, { code: string; kind: ErrorKind; text: st
},
};
/**
* A `failed` the engine went on to retry: expected, exactly like timeout/malformed — the
* engine's defined handling path absorbed it and the user never lost anything, so it belongs
* in the record but not in an operator's queue. Its own code so the exhausted case keeps
* `llm_failed` to itself and neither can dedup the other away.
*/
const LLM_FAILED_RETRIED: FailureSpec = {
code: "llm_failed_retried",
kind: "expected",
text: "LLM request failed (the engine reconnects and retries).",
};
/** Recorded tool failure states (`aborted` = denial/interruption, not an error). */
type ToolFailure = "failed" | "timeout";
@@ -267,12 +310,15 @@ export class StreamErrorWatcher {
}
return;
}
// A new attempt begins (the engine is retrying): no reason text left to wait for the previous failure, persist using the status text.
// A new attempt begins (the engine is retrying): no reason text left to wait for the
// previous failure, persist using the status text — and this is the proof the ladder
// carried it, which is what downgrades a `failed` from unexpected to expected.
if (p.type === "request_begin") {
this.flush(key);
this.flush(key, null, true);
return;
}
// Interrupted/failed exit: reason is core's only failure-reason text.
// Interrupted/failed exit: reason is core's only failure-reason text. No attempt follows
// an abort, so a `failed` resolved here is one the retries did not recover.
if (p.type === "abort") {
this.flush(key, typeof p.reason === "string" ? p.reason : null);
}
@@ -280,15 +326,22 @@ export class StreamErrorWatcher {
/**
* Persist a pending LLM failure (no-op if none is pending); `reason` is the abort
* message that arrived afterward. Pending state is already bucketed by origin, so
* message that arrived afterward, and `retried` says whether another attempt followed
* (only a `request_begin` proves that). Pending state is already bucketed by origin, so
* `key` is exactly "the session that produced this failure" — attribution is looked
* up from it (see file header).
*
* `retried` defaults to false so every other resolution — an abort, the defensive flush at
* the next request_end, or close() at the end of the run — is read as "nothing came after
* this failure". That is the conservative direction: at worst a recovered blip is escalated,
* never a lost turn silently downgraded.
*/
private flush(key: string, reason?: string | null): void {
private flush(key: string, reason?: string | null, retried = false): void {
const entry = this.pending.get(key);
if (entry === undefined) return;
this.pending.delete(key);
const spec = LLM_FAILURES[entry.status];
const spec =
retried && entry.status === "failed" ? LLM_FAILED_RETRIED : LLM_FAILURES[entry.status];
const trimmed = reason?.trim();
// Message priority: the abort reason (core's failure prose) → the staged request_end's
// own detail (the retry path: no abort ever arrives) → the generic status text. A
+16 -6
View File
@@ -372,7 +372,7 @@ export class TraceService {
if (pendingFrom === null) pendingFrom = mi;
// A user Prompt **always starts a new turn**: judging continuation solely
// by "did the previous turn call a tool" isn't enough — if the previous
// turn ended in timeout/malformed (given up after exhausting retries),
// turn ended in a retryable status (given up after exhausting retries),
// retryable would leave continuation at true, and this new message would
// get merged into that failed turn, smearing the two turns' messages /
// Tokens / TPS / duration together.
@@ -437,11 +437,21 @@ export class TraceService {
}
} else if (p.type === "request_end") {
const status = typeof p.status === "string" ? p.status : undefined;
// timeout/malformed is automatically reconnected by core within the same
// run (context-engine's retry loop); the resent Request still belongs to
// **the same user turn**: it must continue the turn, otherwise a single
// timeout would split that turn's Tokens/duration/TPS across two Tasks.
const retryable = status === "timeout" || status === "malformed";
// A status core reconnects on within the same run (context-engine's retry
// loop) leaves the resent Request in **the same user turn**: it must
// continue the turn, otherwise a single blip would split that turn's
// Tokens/duration/TPS across two Tasks and inflate the Task count.
//
// This list must track the engine's, and the engine's differs by loop:
// the turn loop retries `failed` too, while compaction deliberately fails
// fast and stops on it (core's TURN_RETRY_STATUSES vs
// COMPACTION_RETRY_STATUSES). Hence the compactionActive guard — a failed
// compaction request really is the end of that request, and counting it
// as a reconnect would invent an attempt that never happened.
const retryable =
status === "timeout" ||
status === "malformed" ||
(status === "failed" && !compactionActive);
if (!hasOrigin) {
prevSerialTs = null;
continuation = sawToolCallThisRequest || retryable;