@crouton-kit/crouter 0.3.64 → 0.3.66
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/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +24 -12
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +41 -16
- package/dist/clients/attach/attach-cmd.js +329 -329
- package/dist/clients/attach/graph-overlay.js +25 -4
- package/dist/commands/node-lifecycle-revive.js +9 -2
- package/dist/core/__tests__/full/broker-crash-teardown.test.js +17 -1
- package/dist/core/__tests__/on-read-crouter-home-fence.test.d.ts +1 -0
- package/dist/core/__tests__/on-read-crouter-home-fence.test.js +67 -0
- package/dist/core/canvas/pid.d.ts +71 -13
- package/dist/core/canvas/pid.js +114 -25
- package/dist/core/runtime/revive-all.js +8 -2
- package/dist/core/runtime/revive.js +18 -8
- package/dist/core/substrate/on-read.js +13 -2
- package/dist/daemon/crtrd.js +18 -8
- package/package.json +1 -1
package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts
CHANGED
|
@@ -1074,10 +1074,10 @@ test("treats a request timeout as transient: retries the SAME provider in place,
|
|
|
1074
1074
|
}
|
|
1075
1075
|
});
|
|
1076
1076
|
|
|
1077
|
-
//
|
|
1078
|
-
//
|
|
1079
|
-
//
|
|
1080
|
-
test("
|
|
1077
|
+
// Once the in-place transient retry budget is exhausted, the failure is surfaced to the
|
|
1078
|
+
// user AND the credential is rotated away: the struggling credential cools down briefly and
|
|
1079
|
+
// the turn continues on the fallback provider, rather than hard-failing.
|
|
1080
|
+
test("surfaces the transient error and rotates to the fallback once the in-place retry budget is exhausted", async () => {
|
|
1081
1081
|
resetRotationState();
|
|
1082
1082
|
writeLadderConfig();
|
|
1083
1083
|
helpers.writeSubscriptionPool("anthropic", [credential("primary")]);
|
|
@@ -1085,6 +1085,10 @@ test("ends the turn without rotating once the transient retry budget is exhauste
|
|
|
1085
1085
|
|
|
1086
1086
|
const { ctx, setModelCalls, providers, events, pi } = makeRotationCtx();
|
|
1087
1087
|
ctx.model = { provider: "anthropic", id: "claude-opus-4-8", api: "anthropic-messages" };
|
|
1088
|
+
const notifications: Array<{ message: string; level: string }> = [];
|
|
1089
|
+
ctx.ui.notify = (message: string, level: string) => {
|
|
1090
|
+
notifications.push({ message, level });
|
|
1091
|
+
};
|
|
1088
1092
|
const streamedModels: any[] = [];
|
|
1089
1093
|
const sleepCalls: number[] = [];
|
|
1090
1094
|
rotationModule.__setSleepForTest(async (ms) => {
|
|
@@ -1093,8 +1097,12 @@ test("ends the turn without rotating once the transient retry budget is exhauste
|
|
|
1093
1097
|
rotationModule.__setStreamForProviderForTest((model) => {
|
|
1094
1098
|
streamedModels.push(model);
|
|
1095
1099
|
return (async function* () {
|
|
1096
|
-
|
|
1097
|
-
|
|
1100
|
+
if (model.provider === "anthropic") {
|
|
1101
|
+
// 503 with no retry-after header is transient by STATUS, not just by message.
|
|
1102
|
+
yield { type: "error", error: Object.assign(new Error("Service Unavailable"), { status: 503 }) };
|
|
1103
|
+
return;
|
|
1104
|
+
}
|
|
1105
|
+
yield { type: "text_delta", text: "hello from codex fallback" };
|
|
1098
1106
|
})() as any;
|
|
1099
1107
|
});
|
|
1100
1108
|
|
|
@@ -1108,13 +1116,17 @@ test("ends the turn without rotating once the transient retry budget is exhauste
|
|
|
1108
1116
|
const emitted = [] as any[];
|
|
1109
1117
|
for await (const event of stream) emitted.push(event);
|
|
1110
1118
|
|
|
1111
|
-
|
|
1112
|
-
assert.
|
|
1119
|
+
// Retried the same anthropic credential in place (3 attempts) then rotated to codex.
|
|
1120
|
+
assert.equal(streamedModels.filter((m) => m.provider === "anthropic").length, 3);
|
|
1113
1121
|
assert.deepEqual(sleepCalls, [250, 750]);
|
|
1114
|
-
assert.
|
|
1115
|
-
|
|
1116
|
-
assert.
|
|
1117
|
-
assert.
|
|
1122
|
+
assert.deepEqual(setModelCalls.map((m) => m.provider), ["openai-codex"]);
|
|
1123
|
+
// The turn was served on the fallback, never failed with a terminal error.
|
|
1124
|
+
assert.ok(emitted.some((e) => e.type === "text_delta"));
|
|
1125
|
+
assert.ok(!emitted.some((e) => e.type === "error"));
|
|
1126
|
+
// The user was told about the transient failure AND that it rotated.
|
|
1127
|
+
assert.ok(notifications.some((n) => /transient error after 3 attempts/.test(n.message) && /rotating/i.test(n.message)));
|
|
1128
|
+
// The struggling anthropic credential was cooled down so rotation moved on.
|
|
1129
|
+
assert.ok(helpers.readSubscriptionPool("anthropic")[0].rateLimitedUntil > Date.now());
|
|
1118
1130
|
} finally {
|
|
1119
1131
|
rotationModule.__setStreamForProviderForTest(undefined);
|
|
1120
1132
|
rotationModule.__setSleepForTest(undefined);
|
|
@@ -47,17 +47,23 @@ const INVALID_REFRESH_TOKEN_BACKOFF_MS = 30 * 24 * 60 * 60 * 1000;
|
|
|
47
47
|
// not a wait. Cap at the same window as our own default rate-limit backoff.
|
|
48
48
|
const SOLE_PROVIDER_MAX_WAIT_MS = DEFAULT_RATE_LIMIT_BACKOFF_MS;
|
|
49
49
|
// Short in-place retry budget for a transient network/5xx blip on the SAME credential
|
|
50
|
-
// (#122): a couple of quick retries, no cooldown
|
|
51
|
-
//
|
|
50
|
+
// (#122): a couple of quick retries, no cooldown. Once this budget is exhausted we no
|
|
51
|
+
// longer hard-fail the turn -- we surface the error and rotate (see TRANSIENT_ROTATE below).
|
|
52
52
|
const TRANSIENT_RETRY_BACKOFFS_MS = [250, 750];
|
|
53
|
+
// After the in-place transient retry budget is exhausted, rotate instead of failing: the
|
|
54
|
+
// credential itself is probably healthy (this is a provider-side blip like "overloaded"),
|
|
55
|
+
// so cool it down only briefly -- long enough to rotate to another credential / fallback
|
|
56
|
+
// provider, short enough that it comes back quickly if it was the only option.
|
|
57
|
+
const TRANSIENT_ROTATE_COOLDOWN_MS = 60 * 1000;
|
|
53
58
|
|
|
54
59
|
let runtimeContext: ExtensionContext | undefined;
|
|
55
60
|
let extensionAPI: ExtensionAPI | undefined;
|
|
56
61
|
|
|
57
62
|
type AttemptResult = {
|
|
58
|
-
result: "success" | "rate_limited" | "fatal";
|
|
63
|
+
result: "success" | "rate_limited" | "transient_exhausted" | "fatal";
|
|
59
64
|
retryAfterMs?: number;
|
|
60
65
|
attemptAt: number;
|
|
66
|
+
attempts?: number;
|
|
61
67
|
classification?: ProviderErrorClassification;
|
|
62
68
|
};
|
|
63
69
|
|
|
@@ -466,21 +472,16 @@ async function streamProviderAttempt(
|
|
|
466
472
|
return { result: "rate_limited", retryAfterMs: requestedRetryAfterMs, attemptAt, classification };
|
|
467
473
|
}
|
|
468
474
|
|
|
469
|
-
// Transient: retry the SAME credential/provider in place -- no cooldown
|
|
475
|
+
// Transient: retry the SAME credential/provider in place first -- no cooldown for the
|
|
476
|
+
// quick in-place budget.
|
|
470
477
|
const backoffMs = TRANSIENT_RETRY_BACKOFFS_MS[attempt - 1];
|
|
471
478
|
if (backoffMs === undefined) {
|
|
472
|
-
//
|
|
473
|
-
//
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
model,
|
|
479
|
-
`${getProviderLabel(providerId)} "${credential.label}" hit a transient error after ${attempt} attempts (${classification.reason})`,
|
|
480
|
-
),
|
|
481
|
-
});
|
|
482
|
-
flush();
|
|
483
|
-
return { result: "fatal", attemptAt, classification };
|
|
479
|
+
// In-place retry budget exhausted. Rather than hard-failing the turn, hand back a
|
|
480
|
+
// transient-exhausted result so the caller surfaces the error to the user AND rotates
|
|
481
|
+
// to another credential / fallback provider. Nothing is emitted into the stream here:
|
|
482
|
+
// the buffered (pre-content) events are discarded exactly like the rate-limit path, so
|
|
483
|
+
// the rotation target streams a clean turn.
|
|
484
|
+
return { result: "transient_exhausted", attemptAt, classification, attempts: attempt };
|
|
484
485
|
}
|
|
485
486
|
logRotation(
|
|
486
487
|
`${getProviderLabel(providerId)} subscription "${credential.label}" hit a transient error (${classification.reason}, status=${classification.status ?? "n/a"}); retrying same credential in ${backoffMs}ms (attempt ${attempt + 1})`,
|
|
@@ -599,6 +600,30 @@ async function runManagedProvider(
|
|
|
599
600
|
logRotation(
|
|
600
601
|
`${getProviderLabel(providerId)} subscription "${freshCredential.label}" rate-limited (status=${result.classification?.status ?? "n/a"}, reason=${result.classification?.reason ?? "unclassified"}, ${retryAfterPart}); cooling down for ${Math.ceil(cooldownMs / 1000)}s, trying next credential`,
|
|
601
602
|
);
|
|
603
|
+
// Surface the rotation to the user too (parity with the transient-exhausted path), so a
|
|
604
|
+
// rate-limit-driven credential/provider switch is visible rather than silent.
|
|
605
|
+
runtimeContext?.ui.notify?.(
|
|
606
|
+
`${getProviderLabel(providerId)} "${freshCredential.label}" rate-limited (cooling down ${Math.ceil(cooldownMs / 1000)}s); rotating to another credential/provider.`,
|
|
607
|
+
"warn",
|
|
608
|
+
);
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
if (result.result === "transient_exhausted") {
|
|
613
|
+
// The credential is likely healthy -- this was a provider-side transient blip (e.g.
|
|
614
|
+
// "overloaded") that survived the in-place retries. Surface it to the user and rotate:
|
|
615
|
+
// cool the credential down briefly so this loop (and the pool-exhaustion logic below)
|
|
616
|
+
// moves on to the next credential / fallback provider instead of failing the turn.
|
|
617
|
+
const reason = result.classification?.reason ?? "transient error";
|
|
618
|
+
markSubscriptionRateLimited(providerId, freshCredential.label, TRANSIENT_ROTATE_COOLDOWN_MS, result.attemptAt);
|
|
619
|
+
runtimeContext?.ui.notify?.(
|
|
620
|
+
`${getProviderLabel(providerId)} "${freshCredential.label}" hit a transient error after ${result.attempts ?? 1} attempts (${reason}); rotating to another credential/provider.`,
|
|
621
|
+
"warn",
|
|
622
|
+
);
|
|
623
|
+
logRotation(
|
|
624
|
+
`${getProviderLabel(providerId)} subscription "${freshCredential.label}" transient error exhausted (reason=${reason}, status=${result.classification?.status ?? "n/a"}); cooling down ${Math.ceil(TRANSIENT_ROTATE_COOLDOWN_MS / 1000)}s and rotating`,
|
|
625
|
+
);
|
|
626
|
+
setStatus(formatStatusLine(providerId));
|
|
602
627
|
continue;
|
|
603
628
|
}
|
|
604
629
|
|