fix(core,server,web,cli): compaction tolerance, retry parity, and failure classification (#174)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yaowei Zheng
2026-08-04 17:27:45 +08:00
committed by GitHub
parent 7015fb0153
commit 6f60b242bd
32 changed files with 741 additions and 326 deletions
+1 -1
View File
@@ -82,7 +82,7 @@ CREATE TABLE IF NOT EXISTS error_records ( -- server-side error capture (the
project_id TEXT, -- nullable: sign-in/registration and process-level errors have no Project context
agent_id TEXT,
session_id TEXT,
source TEXT NOT NULL, -- http | session | usage | title | subagent | process | llm | environment | schedule
source TEXT NOT NULL, -- http | session | usage | title | subagent | process | llm | environment | compaction | schedule
kind TEXT NOT NULL, -- expected (HttpError, business 4xx) | unexpected (500/runtime)
code TEXT NOT NULL, -- HttpError.code / internal / session_run_failed / ...
status INTEGER, -- HTTP status code; NULL for non-HTTP sources
@@ -61,6 +61,7 @@ export type ErrorSource =
| "session"
| "llm"
| "environment"
| "compaction"
| "usage"
| "title"
| "subagent"
@@ -4,7 +4,8 @@
* converges them into the message stream (LLM and Environment handle errors
* internally and never throw), so a try/catch can't catch a single one. This watcher
* hooks onto SessionManager's drive, inspects messages one by one, and fishes them out
* into error_records (source = `llm` / `environment`), matching usage-recorder's shape:
* into error_records (source = `llm` / `environment` / `compaction`), matching
* usage-recorder's shape:
* recognizes only a few payload types, no-op on the rest. **One instance per run/compact**
* (its state wraps up accordingly, see close).
*
@@ -27,14 +28,14 @@
* - `aborted` / `completed` are not recorded (the former is a user-initiated interrupt,
* not an error).
*
* The message uses the real reason: `request_end` carries the failure detail on
* non-completed statuses (`message`, from LLMOutcome), and the `abort` event's reason is
* The message uses the real reason: `request_end` carries the error detail on
* non-completed statuses (`error_message`, from LLMOutcome), and the `abort` event's reason is
* core's failure-reason prose (e.g. `llm request error: 401 …` / `malformed response
* failed after N retries`). A `request_end` failure is first held pending (status + its
* own detail), not persisted immediately, and is resolved at the next request boundary:
* - 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
* arrive) → use the staged request_end's own `error_message` detail (e.g. a quota
* 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.
@@ -295,17 +296,23 @@ export class StreamErrorWatcher {
type?: string;
status?: StopReason;
reason?: string | null;
message?: string;
error_message?: string;
};
const key = originKey(msg);
if (p.type === "compaction_end") {
this.observeCompactionEnd(msg, key);
return;
}
if (p.type === "request_end") {
this.flush(key); // Defensive: if a previous failure is still pending (normally resolved by request_begin), persist it first
if (isLlmFailure(p.status)) {
this.pending.set(key, {
status: p.status,
// The event's own failure detail (LLMOutcome.message): the message of record when
// no abort follows (the retry path).
...(typeof p.message === "string" && p.message.trim() ? { message: p.message } : {}),
// The event's own error detail (request_end.error_message): the message of record
// when no abort follows (the retry path).
...(typeof p.error_message === "string" && p.error_message.trim()
? { message: p.error_message }
: {}),
});
}
return;
@@ -356,6 +363,43 @@ export class StreamErrorWatcher {
});
}
/**
* A failed compaction becomes its own error record (source = `compaction`) — a compaction
* is an ordinary LLM request whose failures (unusable summary and transport alike) core
* retries under the standard budget, so a `failed` end means the retries ran out
* (issue #170). This is the single record a failed compaction produces: the compaction
* request's own request_begin/request_end pair is written to Trace only and never reaches
* this stream, so the llm-source records above cannot double-count it. `completed` is not
* an error and `aborted` is a user interrupt — neither is recorded. kind is unexpected for
* the same reason `llm_failed` is: the budget is exhausted, and while the original context
* is kept, the trigger still holds — the session keeps re-entering compaction until a
* human changes something. The message carries the attempts and the last error detail
* (compaction_end's share of the RetryDetail block) so the cost center shows what failed.
*/
private observeCompactionEnd(msg: OmniMessage, key: string): void {
const p = msg.payload as {
mode?: string;
reason?: string;
status?: StopReason;
attempt?: number;
error_message?: string;
};
if (p.status !== "failed") return;
const attempts =
typeof p.attempt === "number" && p.attempt > 0
? ` after ${p.attempt} attempt${p.attempt === 1 ? "" : "s"}`
: "";
const detail =
typeof p.error_message === "string" && p.error_message ? `: ${p.error_message}` : "";
this.errors.record({
source: "compaction",
err: `${p.mode ?? "summarize"} compaction failed${attempts}${detail}; trigger ${p.reason ?? "unknown"}, original context kept.`,
ctx: this.ctxFor(key),
code: "compaction_failed",
kind: "unexpected",
});
}
// —— Environment (tool execution) ——
private observeTool(msg: OmniMessage): void {
@@ -608,7 +608,8 @@ export function probeVerdict(
): { ok: true } | { ok: false; message: string } {
if (outcome.status === "completed") return { ok: true };
if (outcome.status === "malformed" && sawContent) return { ok: true };
const detail = "message" in outcome && outcome.message ? outcome.message : outcome.status;
const detail =
"errorMessage" in outcome && outcome.errorMessage ? outcome.errorMessage : outcome.status;
return { ok: false, message: String(detail).slice(0, 300) };
}