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
+25 -10
View File
@@ -22,8 +22,8 @@
* token_usage counts toward this level's stats (same convention as the CLI).
* - Events: approval_decision annotates the corresponding tool card
* (labeled "manual" if clicked on this end, "automatic" otherwise);
* abort → an interruption marker item; request_end ending in
* timeout/malformed → a retry-hint item (the engine discards that
* abort → an interruption marker item; request_end ending in any status
* the engine reconnects on (failed/timeout/malformed) → a retry-hint item (the engine discards that
* attempt and resends the original input; the next request_begin marks the hint as resent, and an
* arriving abort marks it as retries exhausted); other request_begin/end
* events aren't rendered (Request duration is covered by Trace
@@ -195,12 +195,23 @@ export interface AbortItem {
reason?: string;
}
/** An LLM Request ending in timeout/malformed → the engine retries carrying the content already produced. */
/**
* The statuses the engine reconnects on — every LLM failure except `auth`, which is terminal
* (see core's TURN_RETRY_STATUSES). A retry the user cannot see is a stalled session with no
* explanation and no way out, so all three render the same countdown and the same controls.
*/
export type ReconnectStatus = "failed" | "timeout" | "malformed";
function isReconnectStatus(status: StopReason | undefined): status is ReconnectStatus {
return status === "failed" || status === "timeout" || status === "malformed";
}
/** An LLM Request ending in failed/timeout/malformed → the engine retries carrying the content already produced. */
export interface ReconnectItem {
kind: "reconnect";
id: number;
/** Trigger reason: timeout (timed out / disconnected) or malformed (an incomplete or unparseable response). */
status: "timeout" | "malformed";
/** Trigger reason: timeout (timed out / disconnected), malformed (an incomplete or unparseable response), or failed (the provider returned an error). */
status: ReconnectStatus;
/** Which retry attempt this is (increments on consecutive failures within the same round; resets to 1 after a request finishes normally). */
attempt: number;
/** The retry request has been sent (set true by the next request_begin). */
@@ -349,7 +360,7 @@ export interface StreamModel {
* dispatches it via `void executeOne`, which doesn't block the streaming loop — execution happens between two Requests).
*/
openApprovalWaitMs: number;
/** Consecutive reconnect-failure count (incremented when request_end is timeout/malformed, reset to zero on any other terminal status). */
/** Consecutive reconnect-failure count (incremented when request_end carries a status the engine reconnects on, reset to zero on any other terminal status). */
reconnectRun: number;
/**
* Timestamp (ms) of the most recent auth failure: a main-session `request_end` with
@@ -1262,9 +1273,9 @@ function handleEvent(model: StreamModel, p: EventPayload, tsMs?: number, nowMs?:
return;
}
case "request_end": {
// timeout/malformed: the engine retries carrying the content already
// produced, rendering a retry hint (with the attempt number); other
// terminal statuses aren't rendered (Request duration is covered by Trace performance
// failed/timeout/malformed: the engine retries carrying the content already
// produced, rendering a retry hint (with the attempt number); the terminal
// statuses aren't rendered (Request duration is covered by Trace performance
// analysis) and reset the consecutive-failure count. request events
// within a compaction range (only visible during history rebuild)
// are neither rendered nor counted — the compaction process only exposes the compaction event pair to the Human.
@@ -1302,7 +1313,11 @@ function handleEvent(model: StreamModel, p: EventPayload, tsMs?: number, nowMs?:
// round, so the pending compaction usage never reaches this step and is discarded at finalization (not counted into this round).
if (tsMs !== undefined) model.taskLastReqEndMs = tsMs;
commitPendingCompaction(model.stats);
if (p.status === "timeout" || p.status === "malformed") {
// Every status the engine reconnects on gets an item, `failed` included: it is retried
// exactly like the other two, so leaving it out would stall the session for the whole
// ladder with nothing on screen and no give-up control. It would also reset the counter
// mid-ladder, renumbering a mixed timeout → failed → timeout run back to retry #1.
if (isReconnectStatus(p.status)) {
model.reconnectRun += 1;
const item: ReconnectItem = {
kind: "reconnect",
+11 -3
View File
@@ -671,15 +671,23 @@ When done, open index.html in a browser and self-test once.`,
modelAuthDeadRetry: "Retry",
modelAuthDeadCta: "New Session",
modelAuthDeadPlaceholder: "Model authentication failed — update the API key first",
/** Reconnect hint line; `secondsLeft` (waiting state only) switches to the live-countdown wording. */
/**
* Reconnect hint line; `secondsLeft` (waiting state only) switches to the live-countdown
* wording. `failed` is in the union because the engine retries it like the other two —
* its cause names the provider rather than the transport, since that is where it came from.
*/
reconnect: (
status: "timeout" | "malformed",
status: "failed" | "timeout" | "malformed",
state: "waiting" | "retried" | "gaveUp",
attempt: number,
secondsLeft?: number,
) => {
const cause =
status === "timeout" ? "Connection timed out" : "Response incomplete or unparseable";
status === "timeout"
? "Connection timed out"
: status === "malformed"
? "Response incomplete or unparseable"
: "The model provider returned an error";
const action =
state === "gaveUp"
? "no further retries"
+12 -3
View File
@@ -654,14 +654,23 @@ Penguin 视觉风格(见 web-design 技能),深色/浅色主题(<html da
modelAuthDeadRetry: "重试",
modelAuthDeadCta: "新建会话",
modelAuthDeadPlaceholder: "模型认证失败,请先更新 API key",
/** Reconnect hint line; `secondsLeft` (waiting state only) switches to the live-countdown wording. */
/**
* Reconnect hint line; `secondsLeft` (waiting state only) switches to the live-countdown
* wording. `failed` is in the union because the engine retries it like the other two —
* its cause names the provider rather than the transport, since that is where it came from.
*/
reconnect: (
status: "timeout" | "malformed",
status: "failed" | "timeout" | "malformed",
state: "waiting" | "retried" | "gaveUp",
attempt: number,
secondsLeft?: number,
) => {
const cause = status === "timeout" ? "连接超时或网络中断" : "响应不完整或无法解析";
const cause =
status === "timeout"
? "连接超时或网络中断"
: status === "malformed"
? "响应不完整或无法解析"
: "模型服务返回错误";
const action =
state === "gaveUp"
? "已停止重试"