@crewhaus/recovery-engine 0.2.3 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +75 -2
- package/dist/index.js +240 -11
- package/package.json +2 -2
package/dist/index.d.ts
CHANGED
|
@@ -5,6 +5,10 @@
|
|
|
5
5
|
* the chosen action (sleeping, recompacting, injecting messages).
|
|
6
6
|
*
|
|
7
7
|
* Taxonomy:
|
|
8
|
+
* billing (402 / credit balance / insufficient_quota)
|
|
9
|
+
* → halt (terminal — retrying an empty account is futile)
|
|
10
|
+
* auth (401/403) → halt (terminal — retrying bad credentials is futile)
|
|
11
|
+
* rate_limit (429) → retry (honors Retry-After; exhaustion → halt, exit 32)
|
|
8
12
|
* prompt_too_long → compact (max once per turn → fail)
|
|
9
13
|
* max_output_tokens → continue (max 3 per turn → fail)
|
|
10
14
|
* overloaded / 5xx → retry (exponential backoff, max 5 → fail)
|
|
@@ -23,7 +27,7 @@
|
|
|
23
27
|
* References: claude-code/query.ts recovery branches; agent-framework
|
|
24
28
|
* _runner.py retry; AI-Harness-Systems §recovery.
|
|
25
29
|
*/
|
|
26
|
-
import { CrewhausError } from "@crewhaus/errors";
|
|
30
|
+
import { CrewhausError, type FailureClass, type FailureReport } from "@crewhaus/errors";
|
|
27
31
|
export type RecoveryAction = {
|
|
28
32
|
readonly kind: "compact";
|
|
29
33
|
} | {
|
|
@@ -40,8 +44,11 @@ export type RecoveryAction = {
|
|
|
40
44
|
} | {
|
|
41
45
|
readonly kind: "fail";
|
|
42
46
|
readonly reason: string;
|
|
47
|
+
} | {
|
|
48
|
+
readonly kind: "halt";
|
|
49
|
+
readonly report: FailureReport;
|
|
43
50
|
};
|
|
44
|
-
export type RecoveryErrorClass = "prompt_too_long" | "max_output_tokens" | "overloaded_or_5xx" | "invalid_request" | "user_aborted" | "unknown";
|
|
51
|
+
export type RecoveryErrorClass = "billing" | "auth" | "rate_limit" | "prompt_too_long" | "max_output_tokens" | "overloaded_or_5xx" | "invalid_request" | "user_aborted" | "unknown";
|
|
45
52
|
export type RecoveryState = {
|
|
46
53
|
/** Number of `retry` actions already chosen in this turn. */
|
|
47
54
|
readonly retryCount: number;
|
|
@@ -64,6 +71,17 @@ export declare class RecoveryEngineError extends CrewhausError {
|
|
|
64
71
|
* 4 s, ..., capped at 30 s, plus 0–250 ms of jitter.
|
|
65
72
|
*/
|
|
66
73
|
export declare function backoffMs(attempt: number, jitterFn?: () => number): number;
|
|
74
|
+
/**
|
|
75
|
+
* v0.3.0 Goal 6 — extract a provider Retry-After delay from an error object,
|
|
76
|
+
* in milliseconds, or undefined when absent. Duck-typed against the shapes
|
|
77
|
+
* the SDKs / adapters surface:
|
|
78
|
+
* · `retryAfterMs` (number, milliseconds) — adapter-normalized field;
|
|
79
|
+
* · `retryAfter` (number, seconds) — SDK convenience field;
|
|
80
|
+
* · `headers` — a Headers instance or plain object carrying `retry-after`
|
|
81
|
+
* as delta-seconds or an HTTP-date.
|
|
82
|
+
* Results are clamped into [0, RETRY_AFTER_CAP_MS].
|
|
83
|
+
*/
|
|
84
|
+
export declare function retryAfterMs(error: unknown, now?: () => number): number | undefined;
|
|
67
85
|
/**
|
|
68
86
|
* Classify an unknown error value into a recovery taxonomy bucket. Duck-typed
|
|
69
87
|
* against the Anthropic SDK error shape: `.status`, `.error.type`, `.message`,
|
|
@@ -74,6 +92,60 @@ export declare function backoffMs(attempt: number, jitterFn?: () => number): num
|
|
|
74
92
|
* that shape after seeing `stop_reason: "max_tokens"`).
|
|
75
93
|
*/
|
|
76
94
|
export declare function classify(error: unknown): RecoveryErrorClass;
|
|
95
|
+
/**
|
|
96
|
+
* v0.3.0 Goal 6 — built-in named failure classes. Report metadata (title /
|
|
97
|
+
* remediation / exit code) for the terminal classes the engine can halt
|
|
98
|
+
* with. Consulted AFTER user `failure_taxonomy` entries (user overrides
|
|
99
|
+
* win) and BEFORE the generic classify buckets. `mcp_boot_failure` and
|
|
100
|
+
* `crewhaus_budget` are not produced by `classify()` — they exist so the
|
|
101
|
+
* runtime's MCP-boot and budget-cap paths build their reports from the
|
|
102
|
+
* same table (wired in the follow-up runtime PR).
|
|
103
|
+
*/
|
|
104
|
+
export type BuiltinFailureClass = {
|
|
105
|
+
readonly class: FailureClass;
|
|
106
|
+
readonly title: string;
|
|
107
|
+
readonly remediation: string;
|
|
108
|
+
readonly exitCode: number;
|
|
109
|
+
readonly docsUrl?: string;
|
|
110
|
+
};
|
|
111
|
+
export declare const BUILTIN_FAILURE_CLASSES: {
|
|
112
|
+
readonly billing_exhausted: {
|
|
113
|
+
readonly class: "billing";
|
|
114
|
+
readonly title: "provider account out of funding";
|
|
115
|
+
readonly remediation: "add credits to your provider account, then rerun.";
|
|
116
|
+
readonly exitCode: 31;
|
|
117
|
+
};
|
|
118
|
+
readonly auth_invalid: {
|
|
119
|
+
readonly class: "auth";
|
|
120
|
+
readonly title: "provider rejected the credentials";
|
|
121
|
+
readonly remediation: "check the provider API key env var (see .env.example), then rerun.";
|
|
122
|
+
readonly exitCode: 30;
|
|
123
|
+
};
|
|
124
|
+
readonly rate_limited: {
|
|
125
|
+
readonly class: "rate_limit";
|
|
126
|
+
readonly title: "provider rate limit still exceeded after retries";
|
|
127
|
+
readonly remediation: "wait for the rate-limit window to reset (or lower the request rate), then rerun.";
|
|
128
|
+
readonly exitCode: 32;
|
|
129
|
+
};
|
|
130
|
+
readonly mcp_boot_failure: {
|
|
131
|
+
readonly class: "mcp_boot";
|
|
132
|
+
readonly title: "an MCP server failed to boot";
|
|
133
|
+
readonly remediation: "check the MCP server command, URL, and credentials in the spec, then rerun.";
|
|
134
|
+
readonly exitCode: 40;
|
|
135
|
+
};
|
|
136
|
+
readonly crewhaus_budget: {
|
|
137
|
+
readonly class: "crewhaus_budget";
|
|
138
|
+
readonly title: "run stopped by the configured budget cap";
|
|
139
|
+
readonly remediation: "raise the spec's budget cap (or rerun with a higher cap).";
|
|
140
|
+
readonly exitCode: 33;
|
|
141
|
+
};
|
|
142
|
+
};
|
|
143
|
+
/**
|
|
144
|
+
* Build a `FailureReport` for a built-in class from the offending error.
|
|
145
|
+
* AdapterError wrappers carry `providerId`, which drives the "…said:"
|
|
146
|
+
* attribution in `detail` and the provider-specific remediation line.
|
|
147
|
+
*/
|
|
148
|
+
export declare function buildFailureReport(key: keyof typeof BUILTIN_FAILURE_CLASSES, error: unknown): FailureReport;
|
|
77
149
|
/**
|
|
78
150
|
* Section 55 (Track A) — a single entry from a spec's `failure_taxonomy`,
|
|
79
151
|
* carried verbatim from the IR. The recovery engine consults the taxonomy
|
|
@@ -117,4 +189,5 @@ export declare const BUDGETS: {
|
|
|
117
189
|
readonly BASE_BACKOFF_MS: 1000;
|
|
118
190
|
readonly MAX_BACKOFF_MS: 30000;
|
|
119
191
|
readonly MAX_JITTER_MS: 250;
|
|
192
|
+
readonly RETRY_AFTER_CAP_MS: 60000;
|
|
120
193
|
};
|
package/dist/index.js
CHANGED
|
@@ -5,6 +5,10 @@
|
|
|
5
5
|
* the chosen action (sleeping, recompacting, injecting messages).
|
|
6
6
|
*
|
|
7
7
|
* Taxonomy:
|
|
8
|
+
* billing (402 / credit balance / insufficient_quota)
|
|
9
|
+
* → halt (terminal — retrying an empty account is futile)
|
|
10
|
+
* auth (401/403) → halt (terminal — retrying bad credentials is futile)
|
|
11
|
+
* rate_limit (429) → retry (honors Retry-After; exhaustion → halt, exit 32)
|
|
8
12
|
* prompt_too_long → compact (max once per turn → fail)
|
|
9
13
|
* max_output_tokens → continue (max 3 per turn → fail)
|
|
10
14
|
* overloaded / 5xx → retry (exponential backoff, max 5 → fail)
|
|
@@ -23,7 +27,7 @@
|
|
|
23
27
|
* References: claude-code/query.ts recovery branches; agent-framework
|
|
24
28
|
* _runner.py retry; AI-Harness-Systems §recovery.
|
|
25
29
|
*/
|
|
26
|
-
import { CrewhausError } from "@crewhaus/errors";
|
|
30
|
+
import { CrewhausError, EXIT_CODES, isRunFailedError, } from "@crewhaus/errors";
|
|
27
31
|
export const initialRecoveryState = {
|
|
28
32
|
retryCount: 0,
|
|
29
33
|
compactCount: 0,
|
|
@@ -59,6 +63,51 @@ export function backoffMs(attempt, jitterFn = Math.random) {
|
|
|
59
63
|
const jitter = Math.min(Math.floor(jitterFn() * (MAX_JITTER_MS + 1)), MAX_JITTER_MS);
|
|
60
64
|
return exp + jitter;
|
|
61
65
|
}
|
|
66
|
+
// Honor a provider Retry-After up to this cap so a pathological header
|
|
67
|
+
// (e.g. an HTTP-date hours away) can't stall the run indefinitely; past the
|
|
68
|
+
// cap the normal capped exponential backoff applies.
|
|
69
|
+
const RETRY_AFTER_CAP_MS = 60_000;
|
|
70
|
+
/**
|
|
71
|
+
* v0.3.0 Goal 6 — extract a provider Retry-After delay from an error object,
|
|
72
|
+
* in milliseconds, or undefined when absent. Duck-typed against the shapes
|
|
73
|
+
* the SDKs / adapters surface:
|
|
74
|
+
* · `retryAfterMs` (number, milliseconds) — adapter-normalized field;
|
|
75
|
+
* · `retryAfter` (number, seconds) — SDK convenience field;
|
|
76
|
+
* · `headers` — a Headers instance or plain object carrying `retry-after`
|
|
77
|
+
* as delta-seconds or an HTTP-date.
|
|
78
|
+
* Results are clamped into [0, RETRY_AFTER_CAP_MS].
|
|
79
|
+
*/
|
|
80
|
+
export function retryAfterMs(error, now = Date.now) {
|
|
81
|
+
if (error === null || typeof error !== "object")
|
|
82
|
+
return undefined;
|
|
83
|
+
const errObj = error;
|
|
84
|
+
const clamp = (ms) => Number.isFinite(ms) ? Math.min(Math.max(Math.round(ms), 0), RETRY_AFTER_CAP_MS) : undefined;
|
|
85
|
+
if (typeof errObj.retryAfterMs === "number")
|
|
86
|
+
return clamp(errObj.retryAfterMs);
|
|
87
|
+
if (typeof errObj.retryAfter === "number")
|
|
88
|
+
return clamp(errObj.retryAfter * 1_000);
|
|
89
|
+
const headers = errObj.headers;
|
|
90
|
+
let raw;
|
|
91
|
+
if (headers !== null && typeof headers === "object") {
|
|
92
|
+
const maybeGet = headers.get;
|
|
93
|
+
if (typeof maybeGet === "function") {
|
|
94
|
+
raw = maybeGet.call(headers, "retry-after");
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
const record = headers;
|
|
98
|
+
raw = record["retry-after"] ?? record["Retry-After"];
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (typeof raw !== "string" || raw.length === 0)
|
|
102
|
+
return undefined;
|
|
103
|
+
// Delta-seconds form ("30") first, then the HTTP-date form.
|
|
104
|
+
if (/^\d+$/.test(raw.trim()))
|
|
105
|
+
return clamp(Number(raw.trim()) * 1_000);
|
|
106
|
+
const at = Date.parse(raw);
|
|
107
|
+
if (Number.isNaN(at))
|
|
108
|
+
return undefined;
|
|
109
|
+
return clamp(at - now());
|
|
110
|
+
}
|
|
62
111
|
/**
|
|
63
112
|
* Classify an unknown error value into a recovery taxonomy bucket. Duck-typed
|
|
64
113
|
* against the Anthropic SDK error shape: `.status`, `.error.type`, `.message`,
|
|
@@ -79,8 +128,44 @@ export function classify(error) {
|
|
|
79
128
|
}
|
|
80
129
|
}
|
|
81
130
|
const innerType = typeof errObj.error?.type === "string" ? errObj.error.type : undefined;
|
|
131
|
+
const innerMessage = typeof errObj.error?.message === "string" ? errObj.error.message : "";
|
|
82
132
|
const message = typeof errObj.message === "string" ? errObj.message : "";
|
|
83
133
|
const status = typeof errObj.status === "number" ? errObj.status : undefined;
|
|
134
|
+
// BOTH code slots are consulted independently (PR 2): a raw SDK error
|
|
135
|
+
// carries the provider code top-level, but adapter WRAPPERS keep
|
|
136
|
+
// CrewhausError's ErrorCode there ("adapter") and surface the provider
|
|
137
|
+
// code on the copied error envelope — a top-level string must not shadow
|
|
138
|
+
// the envelope's discriminator.
|
|
139
|
+
const code = typeof errObj.code === "string" ? errObj.code : undefined;
|
|
140
|
+
const innerCode = typeof errObj.error?.code === "string" ? errObj.error.code : undefined;
|
|
141
|
+
// v0.3.0 Goal 6 — billing / auth / rate_limit come BEFORE the pre-existing
|
|
142
|
+
// buckets so an out-of-funds 400 is not misrouted through a tombstone and
|
|
143
|
+
// an insufficient_quota 429 is not burned through backoff retries.
|
|
144
|
+
//
|
|
145
|
+
// billing — the provider account itself is out of funding:
|
|
146
|
+
// · HTTP 402 anywhere;
|
|
147
|
+
// · Anthropic: 400 invalid_request_error + "credit balance … too low";
|
|
148
|
+
// · OpenAI: 429 + code "insufficient_quota" (distinct from rate limits);
|
|
149
|
+
// · Bedrock: ServiceQuotaExceededException by name (a hard account
|
|
150
|
+
// quota — retrying won't help; raise the quota instead).
|
|
151
|
+
if (status === 402)
|
|
152
|
+
return "billing";
|
|
153
|
+
if (code === "insufficient_quota" || innerCode === "insufficient_quota")
|
|
154
|
+
return "billing";
|
|
155
|
+
if (status === 400 && /credit balance/i.test(innerMessage.length > 0 ? innerMessage : message)) {
|
|
156
|
+
return "billing";
|
|
157
|
+
}
|
|
158
|
+
if (typeof errObj.name === "string" && /ServiceQuotaExceeded/.test(errObj.name)) {
|
|
159
|
+
return "billing";
|
|
160
|
+
}
|
|
161
|
+
// auth — runtime 401/403 (distinct from boot-time ProviderAuthError, which
|
|
162
|
+
// fires before any request when credentials are entirely missing).
|
|
163
|
+
if (status === 401 || status === 403)
|
|
164
|
+
return "auth";
|
|
165
|
+
// rate_limit — a genuine 429 (NOT insufficient_quota, handled above).
|
|
166
|
+
// Retried with Retry-After honored; budget exhaustion halts with exit 32.
|
|
167
|
+
if (status === 429 || innerType === "rate_limit_error")
|
|
168
|
+
return "rate_limit";
|
|
84
169
|
if (innerType === "max_output_tokens")
|
|
85
170
|
return "max_output_tokens";
|
|
86
171
|
// Anthropic returns "Prompt is too long" inside invalid_request_error responses;
|
|
@@ -98,6 +183,100 @@ export function classify(error) {
|
|
|
98
183
|
return "invalid_request";
|
|
99
184
|
return "unknown";
|
|
100
185
|
}
|
|
186
|
+
export const BUILTIN_FAILURE_CLASSES = {
|
|
187
|
+
billing_exhausted: {
|
|
188
|
+
class: "billing",
|
|
189
|
+
title: "provider account out of funding",
|
|
190
|
+
remediation: "add credits to your provider account, then rerun.",
|
|
191
|
+
exitCode: EXIT_CODES.billing,
|
|
192
|
+
},
|
|
193
|
+
auth_invalid: {
|
|
194
|
+
class: "auth",
|
|
195
|
+
title: "provider rejected the credentials",
|
|
196
|
+
remediation: "check the provider API key env var (see .env.example), then rerun.",
|
|
197
|
+
exitCode: EXIT_CODES.auth,
|
|
198
|
+
},
|
|
199
|
+
rate_limited: {
|
|
200
|
+
class: "rate_limit",
|
|
201
|
+
title: "provider rate limit still exceeded after retries",
|
|
202
|
+
remediation: "wait for the rate-limit window to reset (or lower the request rate), then rerun.",
|
|
203
|
+
exitCode: EXIT_CODES.rate_limit,
|
|
204
|
+
},
|
|
205
|
+
mcp_boot_failure: {
|
|
206
|
+
class: "mcp_boot",
|
|
207
|
+
title: "an MCP server failed to boot",
|
|
208
|
+
remediation: "check the MCP server command, URL, and credentials in the spec, then rerun.",
|
|
209
|
+
exitCode: EXIT_CODES.tool,
|
|
210
|
+
},
|
|
211
|
+
crewhaus_budget: {
|
|
212
|
+
class: "crewhaus_budget",
|
|
213
|
+
title: "run stopped by the configured budget cap",
|
|
214
|
+
remediation: "raise the spec's budget cap (or rerun with a higher cap).",
|
|
215
|
+
exitCode: EXIT_CODES.crewhaus_budget,
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
/** Provider display names for `detail` attribution ("Anthropic said: …"). */
|
|
219
|
+
const PROVIDER_DISPLAY_NAMES = {
|
|
220
|
+
anthropic: "Anthropic",
|
|
221
|
+
openai: "OpenAI",
|
|
222
|
+
gemini: "Gemini",
|
|
223
|
+
bedrock: "Bedrock",
|
|
224
|
+
};
|
|
225
|
+
/** Per-provider remediation overrides for the billing / auth classes. */
|
|
226
|
+
const PROVIDER_REMEDIATIONS = {
|
|
227
|
+
anthropic: {
|
|
228
|
+
billing: "add credits at https://console.anthropic.com/settings/billing, then rerun.",
|
|
229
|
+
auth: "check ANTHROPIC_AUTH_TOKEN / ANTHROPIC_API_KEY (see .env.example), then rerun.",
|
|
230
|
+
},
|
|
231
|
+
openai: {
|
|
232
|
+
billing: "add credits at https://platform.openai.com/settings/organization/billing, then rerun.",
|
|
233
|
+
auth: "check OPENAI_API_KEY (see .env.example), then rerun.",
|
|
234
|
+
},
|
|
235
|
+
gemini: {
|
|
236
|
+
billing: "check your plan and billing at https://aistudio.google.com/, then rerun.",
|
|
237
|
+
auth: "check GEMINI_API_KEY / GOOGLE_API_KEY (see .env.example), then rerun.",
|
|
238
|
+
},
|
|
239
|
+
bedrock: {
|
|
240
|
+
billing: "request a service-quota increase in the AWS console, then rerun.",
|
|
241
|
+
auth: "check your AWS credentials and Bedrock model access, then rerun.",
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
/** Best raw provider text: the API envelope message, else the wrapper message. */
|
|
245
|
+
function rawProviderText(error) {
|
|
246
|
+
const errObj = error;
|
|
247
|
+
const inner = typeof errObj?.error?.message === "string" ? errObj.error.message : "";
|
|
248
|
+
if (inner.length > 0)
|
|
249
|
+
return inner;
|
|
250
|
+
const outer = typeof errObj?.message === "string" ? errObj.message : "";
|
|
251
|
+
if (outer.length > 0)
|
|
252
|
+
return outer;
|
|
253
|
+
return String(error);
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Build a `FailureReport` for a built-in class from the offending error.
|
|
257
|
+
* AdapterError wrappers carry `providerId`, which drives the "…said:"
|
|
258
|
+
* attribution in `detail` and the provider-specific remediation line.
|
|
259
|
+
*/
|
|
260
|
+
export function buildFailureReport(key, error) {
|
|
261
|
+
const builtin = BUILTIN_FAILURE_CLASSES[key];
|
|
262
|
+
const providerId = typeof error?.providerId === "string"
|
|
263
|
+
? error.providerId
|
|
264
|
+
: undefined;
|
|
265
|
+
const raw = rawProviderText(error);
|
|
266
|
+
const detail = providerId !== undefined
|
|
267
|
+
? `${PROVIDER_DISPLAY_NAMES[providerId] ?? providerId} said: ${JSON.stringify(raw)}`
|
|
268
|
+
: raw;
|
|
269
|
+
const remediation = (providerId !== undefined ? PROVIDER_REMEDIATIONS[providerId]?.[builtin.class] : undefined) ??
|
|
270
|
+
builtin.remediation;
|
|
271
|
+
return {
|
|
272
|
+
class: builtin.class,
|
|
273
|
+
title: builtin.title,
|
|
274
|
+
detail,
|
|
275
|
+
remediation,
|
|
276
|
+
exitCode: builtin.exitCode,
|
|
277
|
+
...(builtin.docsUrl !== undefined ? { docsUrl: builtin.docsUrl } : {}),
|
|
278
|
+
};
|
|
279
|
+
}
|
|
101
280
|
/**
|
|
102
281
|
* Match an unknown error against a taxonomy entry's `pattern`. The pattern
|
|
103
282
|
* is either a `/regex/flags` literal or a case-insensitive substring of
|
|
@@ -142,16 +321,44 @@ export function matchNamedFailure(error, taxonomy) {
|
|
|
142
321
|
* built-in classes). Unmatched errors fall through to the built-in flow.
|
|
143
322
|
*/
|
|
144
323
|
export function recover(error, state, taxonomy) {
|
|
324
|
+
// v0.3.0 §7.1 — an error that ALREADY carries a classified terminal report
|
|
325
|
+
// (a child run's RunFailedError escalated through the sub-agent spawner)
|
|
326
|
+
// halts with that report verbatim: no re-classification, no retry ladder,
|
|
327
|
+
// and the user taxonomy cannot downgrade an already-terminal verdict —
|
|
328
|
+
// "a billing failure anywhere ends the run with the billing message".
|
|
329
|
+
if (isRunFailedError(error)) {
|
|
330
|
+
return { kind: "halt", report: error.report };
|
|
331
|
+
}
|
|
145
332
|
if (taxonomy !== undefined && taxonomy.length > 0) {
|
|
146
333
|
const named = matchNamedFailure(error, taxonomy);
|
|
147
334
|
if (named !== undefined) {
|
|
148
|
-
return recoverNamed(named, state);
|
|
335
|
+
return recoverNamed(named, state, error);
|
|
149
336
|
}
|
|
150
337
|
}
|
|
151
338
|
const klass = classify(error);
|
|
152
339
|
const message = error?.message;
|
|
153
340
|
const reasonStr = typeof message === "string" && message.length > 0 ? message : klass;
|
|
154
341
|
switch (klass) {
|
|
342
|
+
// v0.3.0 Goal 6 — terminal classes halt immediately: no tombstone
|
|
343
|
+
// detour, no futile backoff retries against an empty account or a
|
|
344
|
+
// rejected key.
|
|
345
|
+
case "billing":
|
|
346
|
+
return { kind: "halt", report: buildFailureReport("billing_exhausted", error) };
|
|
347
|
+
case "auth":
|
|
348
|
+
return { kind: "halt", report: buildFailureReport("auth_invalid", error) };
|
|
349
|
+
case "rate_limit": {
|
|
350
|
+
// A genuine rate limit is transient: keep retrying, but honor the
|
|
351
|
+
// provider's Retry-After for the delay when one is present. Budget
|
|
352
|
+
// exhaustion is a classified halt (exit 32), not a generic fail.
|
|
353
|
+
if (state.retryCount >= MAX_RETRIES) {
|
|
354
|
+
return { kind: "halt", report: buildFailureReport("rate_limited", error) };
|
|
355
|
+
}
|
|
356
|
+
return {
|
|
357
|
+
kind: "retry",
|
|
358
|
+
delayMs: retryAfterMs(error) ?? backoffMs(state.retryCount),
|
|
359
|
+
attempt: state.retryCount + 1,
|
|
360
|
+
};
|
|
361
|
+
}
|
|
155
362
|
case "prompt_too_long":
|
|
156
363
|
if (state.compactCount >= MAX_COMPACTS) {
|
|
157
364
|
return { kind: "fail", reason: `compact budget exhausted: ${reasonStr}` };
|
|
@@ -185,15 +392,35 @@ export function recover(error, state, taxonomy) {
|
|
|
185
392
|
/**
|
|
186
393
|
* Convert a matched named-failure entry into the corresponding
|
|
187
394
|
* RecoveryAction, respecting the same per-turn budgets as built-in
|
|
188
|
-
* classes. Budget exhaustion always returns
|
|
189
|
-
* declare an infinite retry loop via taxonomy.
|
|
395
|
+
* classes. Budget exhaustion always returns a terminal action so the
|
|
396
|
+
* user can't declare an infinite retry loop via taxonomy.
|
|
397
|
+
*
|
|
398
|
+
* v0.3.0 Goal 6 — the entry's `hint` field (declared since §55, never
|
|
399
|
+
* consumed until now) finally reaches the user: when a matched entry
|
|
400
|
+
* resolves terminally (declared `fail` or budget exhaustion) AND carries a
|
|
401
|
+
* `hint`, the result is a `halt` whose report carries the hint as the
|
|
402
|
+
* remediation line. Entries without a hint keep the pre-0.3.0 `fail`.
|
|
190
403
|
*/
|
|
191
|
-
function recoverNamed(named, state) {
|
|
404
|
+
function recoverNamed(named, state, error) {
|
|
192
405
|
const reason = `failure_taxonomy: ${named.class}`;
|
|
406
|
+
const terminal = (fullReason) => {
|
|
407
|
+
if (named.hint === undefined)
|
|
408
|
+
return { kind: "fail", reason: fullReason };
|
|
409
|
+
return {
|
|
410
|
+
kind: "halt",
|
|
411
|
+
report: {
|
|
412
|
+
class: "unknown",
|
|
413
|
+
title: fullReason,
|
|
414
|
+
detail: error === undefined ? "" : rawProviderText(error),
|
|
415
|
+
remediation: named.hint,
|
|
416
|
+
exitCode: EXIT_CODES.generic,
|
|
417
|
+
},
|
|
418
|
+
};
|
|
419
|
+
};
|
|
193
420
|
switch (named.recovery) {
|
|
194
421
|
case "retry":
|
|
195
422
|
if (state.retryCount >= MAX_RETRIES) {
|
|
196
|
-
return
|
|
423
|
+
return terminal(`retry budget exhausted: ${reason}`);
|
|
197
424
|
}
|
|
198
425
|
return {
|
|
199
426
|
kind: "retry",
|
|
@@ -202,26 +429,26 @@ function recoverNamed(named, state) {
|
|
|
202
429
|
};
|
|
203
430
|
case "compact":
|
|
204
431
|
if (state.compactCount >= MAX_COMPACTS) {
|
|
205
|
-
return
|
|
432
|
+
return terminal(`compact budget exhausted: ${reason}`);
|
|
206
433
|
}
|
|
207
434
|
return { kind: "compact" };
|
|
208
435
|
case "continue":
|
|
209
436
|
if (state.continueCount >= MAX_CONTINUES) {
|
|
210
|
-
return
|
|
437
|
+
return terminal(`continue budget exhausted: ${reason}`);
|
|
211
438
|
}
|
|
212
439
|
return { kind: "continue" };
|
|
213
440
|
case "tombstone":
|
|
214
441
|
if (state.tombstoneCount >= MAX_TOMBSTONES) {
|
|
215
|
-
return
|
|
442
|
+
return terminal(`tombstone budget exhausted: ${reason}`);
|
|
216
443
|
}
|
|
217
444
|
return { kind: "tombstone" };
|
|
218
445
|
case "switch-model":
|
|
219
446
|
if (state.switchModelCount >= MAX_SWITCH_MODELS) {
|
|
220
|
-
return
|
|
447
|
+
return terminal(`switch-model budget exhausted: ${reason}`);
|
|
221
448
|
}
|
|
222
449
|
return { kind: "switch-model" };
|
|
223
450
|
case "fail":
|
|
224
|
-
return
|
|
451
|
+
return terminal(reason);
|
|
225
452
|
}
|
|
226
453
|
}
|
|
227
454
|
/**
|
|
@@ -241,6 +468,7 @@ export function advanceState(state, action) {
|
|
|
241
468
|
case "switch-model":
|
|
242
469
|
return { ...state, switchModelCount: state.switchModelCount + 1 };
|
|
243
470
|
case "fail":
|
|
471
|
+
case "halt":
|
|
244
472
|
return state;
|
|
245
473
|
}
|
|
246
474
|
}
|
|
@@ -254,4 +482,5 @@ export const BUDGETS = {
|
|
|
254
482
|
BASE_BACKOFF_MS,
|
|
255
483
|
MAX_BACKOFF_MS,
|
|
256
484
|
MAX_JITTER_MS,
|
|
485
|
+
RETRY_AFTER_CAP_MS,
|
|
257
486
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crewhaus/recovery-engine",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Pure recovery taxonomy: classify Anthropic API errors into a RecoveryAction (compact/retry/continue/tombstone/fail)",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"test": "bun test src"
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@crewhaus/errors": "0.
|
|
18
|
+
"@crewhaus/errors": "0.3.0"
|
|
19
19
|
},
|
|
20
20
|
"license": "Apache-2.0",
|
|
21
21
|
"author": {
|