@lawaty/lacode 2.3.0-beta.0 → 2.3.0-beta.2
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/CHANGELOG.md +29 -0
- package/dist/agents.js +2 -0
- package/dist/commands.js +2 -0
- package/dist/index.js +55 -5
- package/dist/lacode.js +2 -0
- package/dist/pool.js +32 -4
- package/dist/tunnel.js +2 -0
- package/docs/USER-STORIES.md +20 -2
- package/package.json +1 -1
- package/scripts/pool-selection.mjs +3 -0
- package/scripts/refresh-pool.mjs +141 -15
- package/scripts/score-models.mjs +18 -1
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,35 @@ commit as the changelog entry it describes.
|
|
|
9
9
|
|
|
10
10
|
## [Unreleased]
|
|
11
11
|
|
|
12
|
+
## [2.3.0-beta.2] — degenerate refreshes never shrink the pool
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- Pool: the refresh checks the live opencode status (`https://status.opencode.ai/api/status`) before scanning. When opencode's serving path (`inference`, `go`, `free`, `payg`) reports degraded and the current pool is intact, the scan is skipped, the pool is kept untouched, and a recheck is scheduled in 5 minutes. The signal is cached for 5 minutes and an unreachable status endpoint never blocks a refresh.
|
|
17
|
+
- Pool: the refresh writer now refuses any selection below half the previous pool (minimum four slots). A degenerate scan keeps the previous `pool.txt`, logs the refusal, and schedules a 5-minute recheck. A thin or missing pool is always allowed to recover.
|
|
18
|
+
- Pool: after any failed run, the recheck happens within 5 minutes even when the stated reset is hours out; a sooner provider reset is still honoured.
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- Pool: the transient retry cadence while degraded is 5 minutes instead of waiting out the full stated reset, so recovery is detected on the next recheck rather than the next 6-hour slot.
|
|
23
|
+
- `refresh-pool --install-timer --stable` now also copies `scripts/provider-status.mjs` to the stable lib dir.
|
|
24
|
+
|
|
25
|
+
## [2.3.0-beta.1] — provider degradation backoff
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
|
|
29
|
+
- Pool: a provider is now treated as degraded when two distinct models on it fail with transient, non-quota errors inside ten minutes. Every slot on that provider is benched for one flat 30-minute `provider-degraded` window (never a strike), `pool_status` names the provider, and a probe re-admits a model as soon as it answers. A single model failure keeps the existing 60-second flat outage window.
|
|
30
|
+
- Pool: `npm run score` stops probing a provider after three consecutive provider-level timeouts or network errors, keeps its previous rows, and warns that the provider is degraded. The scan budget is reserved for healthy providers.
|
|
31
|
+
- `oc-sync`: the local `file://.../lacode/dist/index.js` plugin entry is rewritten to the published `@lawaty/lacode@<version>` spec before syncing, so a remote host never receives a path that only exists on the development machine.
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
|
|
35
|
+
- Pool: a flat outage window can now be longer than 60 seconds for a provider-wide degradation; it still never enters the strike memory, so recovery starts clean.
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
|
|
39
|
+
- Pool: repeated timeouts on a degraded provider no longer re-probe every model every few minutes or inflate each model's strike count.
|
|
40
|
+
|
|
12
41
|
## [2.3.0-beta.0] — reliable pool refresh, autonomy modes, and empty-response failover
|
|
13
42
|
|
|
14
43
|
### Added
|
package/dist/agents.js
CHANGED
|
@@ -35,6 +35,8 @@ var STUCK_IDLE_MS = 3 * 60 * 1e3;
|
|
|
35
35
|
var MAX_CLAIM_AGE_MS = 30 * 60 * 1e3;
|
|
36
36
|
var HANG_COOLDOWN_MS = 10 * 60 * 1e3;
|
|
37
37
|
var UNAVAILABLE_COOLDOWN_MS = 60 * 1e3;
|
|
38
|
+
var PROVIDER_DEGRADED_COOLDOWN_MS = 30 * 60 * 1e3;
|
|
39
|
+
var PROVIDER_DEGRADED_WINDOW_MS = 10 * 60 * 1e3;
|
|
38
40
|
var COOLDOWN_BASE_MS = 60 * 1e3;
|
|
39
41
|
var COOLDOWN_MAX_MS = 15 * 60 * 1e3;
|
|
40
42
|
var QUOTA_COOLDOWN_BASE_MS = 15 * 60 * 1e3;
|
package/dist/commands.js
CHANGED
|
@@ -39,6 +39,8 @@ var STUCK_IDLE_MS = 3 * 60 * 1e3;
|
|
|
39
39
|
var MAX_CLAIM_AGE_MS = 30 * 60 * 1e3;
|
|
40
40
|
var HANG_COOLDOWN_MS = 10 * 60 * 1e3;
|
|
41
41
|
var UNAVAILABLE_COOLDOWN_MS = 60 * 1e3;
|
|
42
|
+
var PROVIDER_DEGRADED_COOLDOWN_MS = 30 * 60 * 1e3;
|
|
43
|
+
var PROVIDER_DEGRADED_WINDOW_MS = 10 * 60 * 1e3;
|
|
42
44
|
var MAX_WEIGHT = 100;
|
|
43
45
|
var COOLDOWN_BASE_MS = 60 * 1e3;
|
|
44
46
|
var COOLDOWN_MAX_MS = 15 * 60 * 1e3;
|
package/dist/index.js
CHANGED
|
@@ -37,6 +37,12 @@ var MAX_CLAIM_AGE_MS = 30 * 60 * 1e3;
|
|
|
37
37
|
var HANG_COOLDOWN_MS = 10 * 60 * 1e3;
|
|
38
38
|
var UNAVAILABLE_COOLDOWN_MS = 60 * 1e3;
|
|
39
39
|
var UNAVAILABLE_REASON = "endpoint-unavailable";
|
|
40
|
+
var PROVIDER_DEGRADED_REASON = "provider-degraded";
|
|
41
|
+
var PROVIDER_DEGRADED_COOLDOWN_MS = 30 * 60 * 1e3;
|
|
42
|
+
var PROVIDER_DEGRADED_WINDOW_MS = 10 * 60 * 1e3;
|
|
43
|
+
var PROVIDER_DEGRADED_MIN_MODELS = 2;
|
|
44
|
+
var TRANSIENT_DEGRADATION_REASONS = /^(?:no answer|no-answer|hang|timeout|timed out|endpoint-unavailable|provider-degraded|empty response|network|server)$/i;
|
|
45
|
+
var FLAT_OUTAGE_REASONS = /* @__PURE__ */ new Set([UNAVAILABLE_REASON, PROVIDER_DEGRADED_REASON]);
|
|
40
46
|
var MAX_WEIGHT = 100;
|
|
41
47
|
var MIN_SLOT_CONTEXT = 1e5;
|
|
42
48
|
var MIN_SLOT_TPM = 2e4;
|
|
@@ -711,7 +717,7 @@ function readLimits(s) {
|
|
|
711
717
|
}
|
|
712
718
|
function recordStrike(s, model, reason, stated) {
|
|
713
719
|
if (!s.dirReady) return;
|
|
714
|
-
if (reason
|
|
720
|
+
if (FLAT_OUTAGE_REASONS.has(reason)) return coolUnavailable(s, model, stated, reason);
|
|
715
721
|
const now = Date.now();
|
|
716
722
|
const slotsForModel = s.slots.filter((candidate) => candidate.model === model);
|
|
717
723
|
const buckets = [...new Set(slotsForModel.map((slot) => slotBucket(slot)))];
|
|
@@ -742,13 +748,14 @@ function recordStrike(s, model, reason, stated) {
|
|
|
742
748
|
}
|
|
743
749
|
return strike;
|
|
744
750
|
}
|
|
745
|
-
function coolUnavailable(s, model) {
|
|
751
|
+
function coolUnavailable(s, model, windowMs = UNAVAILABLE_COOLDOWN_MS, reason = UNAVAILABLE_REASON) {
|
|
746
752
|
if (!s.dirReady) return;
|
|
747
753
|
const now = Date.now();
|
|
754
|
+
const requested = Number.isFinite(windowMs) && windowMs > 0 ? Math.min(windowMs, STATED_COOLDOWN_MAX_MS) : UNAVAILABLE_COOLDOWN_MS;
|
|
748
755
|
const prior = s.cooling.get(model);
|
|
749
|
-
const until = now +
|
|
756
|
+
const until = now + requested;
|
|
750
757
|
if (prior && prior.until > until) return prior;
|
|
751
|
-
const strike = { model, until, strikes: 1, reason
|
|
758
|
+
const strike = { model, until, strikes: 1, reason, last: now };
|
|
752
759
|
s.cooling.set(model, strike);
|
|
753
760
|
const path4 = join2(s.dir, limitFile(model));
|
|
754
761
|
try {
|
|
@@ -992,6 +999,24 @@ function coolProvider(s, provider, until, reason) {
|
|
|
992
999
|
}
|
|
993
1000
|
return affected;
|
|
994
1001
|
}
|
|
1002
|
+
function noteProviderDegradation(s, model, reason) {
|
|
1003
|
+
if (!s.dirReady) return [];
|
|
1004
|
+
if (!TRANSIENT_DEGRADATION_REASONS.test(String(reason))) return [];
|
|
1005
|
+
const provider = providerOf(model);
|
|
1006
|
+
const slots = (s.slots.length > 0 ? s.slots : FALLBACK_SLOTS).filter((slot) => providerOf(slot.model) === provider);
|
|
1007
|
+
if (slots.length < PROVIDER_DEGRADED_MIN_MODELS) return [];
|
|
1008
|
+
const now = Date.now();
|
|
1009
|
+
const failing = /* @__PURE__ */ new Set();
|
|
1010
|
+
for (const slot of slots) {
|
|
1011
|
+
const record = s.cooling.get(slot.model);
|
|
1012
|
+
if (!record || record.until <= now) continue;
|
|
1013
|
+
if (now - record.last > PROVIDER_DEGRADED_WINDOW_MS) continue;
|
|
1014
|
+
if (!TRANSIENT_DEGRADATION_REASONS.test(String(record.reason))) continue;
|
|
1015
|
+
failing.add(slot.model);
|
|
1016
|
+
}
|
|
1017
|
+
if (failing.size < PROVIDER_DEGRADED_MIN_MODELS) return [];
|
|
1018
|
+
return coolProvider(s, provider, now + PROVIDER_DEGRADED_COOLDOWN_MS, PROVIDER_DEGRADED_REASON);
|
|
1019
|
+
}
|
|
995
1020
|
function recordFailure(s, f) {
|
|
996
1021
|
try {
|
|
997
1022
|
const path4 = join2(s.dir, "failures.jsonl");
|
|
@@ -3349,12 +3374,31 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
|
|
|
3349
3374
|
}
|
|
3350
3375
|
};
|
|
3351
3376
|
const MAX_ABORT_ATTEMPTS = 3;
|
|
3377
|
+
const markProviderDegradation = async (log2, model, reason) => {
|
|
3378
|
+
const provider = providerOf(model);
|
|
3379
|
+
const slots = s.slots.length > 0 ? s.slots : FALLBACK_SLOTS;
|
|
3380
|
+
const already = slots.some(
|
|
3381
|
+
(slot) => providerOf(slot.model) === provider && s.cooling.get(slot.model)?.reason === PROVIDER_DEGRADED_REASON
|
|
3382
|
+
);
|
|
3383
|
+
const cooled = noteProviderDegradation(s, model, reason);
|
|
3384
|
+
if (!already && cooled.length > 0) {
|
|
3385
|
+
const until = new Date(Date.now() + PROVIDER_DEGRADED_COOLDOWN_MS).toISOString().slice(11, 19);
|
|
3386
|
+
await log2("warn", `provider ${provider} degraded; ${cooled.length} slot(s) benched until ${until}Z`, {
|
|
3387
|
+
provider,
|
|
3388
|
+
reason,
|
|
3389
|
+
models: cooled.map((record) => record.model)
|
|
3390
|
+
});
|
|
3391
|
+
}
|
|
3392
|
+
};
|
|
3352
3393
|
const coolForHang = async (claim, log2) => {
|
|
3353
3394
|
const now = Date.now();
|
|
3354
3395
|
const prior = s.cooling.get(claim.m);
|
|
3355
3396
|
if (prior && prior.until > now) return;
|
|
3356
3397
|
const strike = recordStrike(s, claim.m, "hang", HANG_FALLBACK_MS);
|
|
3357
|
-
if (strike)
|
|
3398
|
+
if (strike) {
|
|
3399
|
+
await log2("warn", `cooling ${claim.m} for ${Math.round((strike.until - now) / 1e3)}s (hang)`);
|
|
3400
|
+
await markProviderDegradation(log2, claim.m, "hang");
|
|
3401
|
+
}
|
|
3358
3402
|
};
|
|
3359
3403
|
const claimFor = (sessionID) => {
|
|
3360
3404
|
if (!sessionID) return void 0;
|
|
@@ -3782,6 +3826,7 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
|
|
|
3782
3826
|
} else if (outage) {
|
|
3783
3827
|
const transient = coolUnavailable(s, own.claim.m);
|
|
3784
3828
|
if (transient) await log("warn", `cooling ${own.claim.m} for ${Math.round((transient.until - Date.now()) / 1e3)}s (${transient.reason})`);
|
|
3829
|
+
await markProviderDegradation(log, own.claim.m, "endpoint-unavailable");
|
|
3785
3830
|
} else {
|
|
3786
3831
|
const stated2 = statedCooldown(message);
|
|
3787
3832
|
const strike2 = recordStrike(s, own.claim.m, named, stated2);
|
|
@@ -3915,6 +3960,7 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
|
|
|
3915
3960
|
if (transient) {
|
|
3916
3961
|
await log("warn", `cooling ${own.claim.m} for ${Math.round((transient.until - Date.now()) / 1e3)}s (${transient.reason}) after a terminal error`);
|
|
3917
3962
|
}
|
|
3963
|
+
await markProviderDegradation(log, own.claim.m, "endpoint-unavailable");
|
|
3918
3964
|
} else {
|
|
3919
3965
|
stated2 = statedCooldown(haystack2) ?? retryAfterMs(error?.data?.responseHeaders);
|
|
3920
3966
|
const strike2 = recordStrike(s, own.claim.m, named, stated2);
|
|
@@ -4087,6 +4133,7 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
|
|
|
4087
4133
|
if (isUnavailableText(haystack) || !limited && !auth && transientGateway) {
|
|
4088
4134
|
const transient = coolUnavailable(s, model);
|
|
4089
4135
|
if (transient) await log("warn", `cooling ${model} for ${Math.round((transient.until - Date.now()) / 1e3)}s (${transient.reason})`);
|
|
4136
|
+
await markProviderDegradation(log, model, "endpoint-unavailable");
|
|
4090
4137
|
await failOver(info.sessionID, model, transient?.reason ?? UNAVAILABLE_REASON, { cool: false });
|
|
4091
4138
|
return;
|
|
4092
4139
|
}
|
|
@@ -4166,6 +4213,7 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
|
|
|
4166
4213
|
const strike = recordStrike(s, spawn4.model, "empty response");
|
|
4167
4214
|
const seconds = strike ? Math.max(1, Math.round((strike.until - Date.now()) / 1e3)) : 0;
|
|
4168
4215
|
await log("warn", `empty response from ${spawn4.model} on slot ${spawn4.slot}; benched ${seconds}s`);
|
|
4216
|
+
await markProviderDegradation(log, spawn4.model, "empty response");
|
|
4169
4217
|
output.output = `Task returned an empty response from ${spawn4.model} (slot ${spawn4.slot}); the model has been benched for ${seconds}s so the next spawn uses another slot.`;
|
|
4170
4218
|
}
|
|
4171
4219
|
spawns.delete(key);
|
|
@@ -4242,6 +4290,7 @@ ${detail}` : detail;
|
|
|
4242
4290
|
};
|
|
4243
4291
|
const total = [...snap.counts.values()].reduce((sum, n) => sum + n, 0);
|
|
4244
4292
|
const coolingNow = [...snap.cooling.entries()].filter(([, st]) => st.until > now);
|
|
4293
|
+
const degradedProviders = [...new Set(coolingNow.filter(([, st]) => st.reason === PROVIDER_DEGRADED_REASON).map(([model]) => providerOf(model)))];
|
|
4245
4294
|
const buckets = /* @__PURE__ */ new Map();
|
|
4246
4295
|
for (const slot of slots) {
|
|
4247
4296
|
const name = slotBucket(slot);
|
|
@@ -4262,6 +4311,7 @@ ${detail}` : detail;
|
|
|
4262
4311
|
one ordered pass in slot order: each slot fills to its weight, then overflow goes to the least-loaded slot. Capacity is the sum of the weights; repeat a model on another line to give it more (FULL = at weight).`,
|
|
4263
4312
|
...next ? [] : ["next: none - no routable slot; every slot is cooling, catalog-retired, or provider-unconfigured. A spawn will fail explicitly instead of routing."],
|
|
4264
4313
|
...sharedBuckets.length ? [`buckets: ${sharedBuckets.map(([name, entry]) => `${name} ${entry.seats} seat(s)${entry.cooling ? " COOLING" : ""}`).join("; ")}`] : [],
|
|
4314
|
+
...degradedProviders.length ? [`provider degraded: ${degradedProviders.join(", ")} (flat window; a probe re-admits a model when it answers)`] : [],
|
|
4265
4315
|
...slots.map(line),
|
|
4266
4316
|
"",
|
|
4267
4317
|
"by process:",
|
package/dist/lacode.js
CHANGED
|
@@ -53,6 +53,8 @@ var STUCK_IDLE_MS = 3 * 60 * 1e3;
|
|
|
53
53
|
var MAX_CLAIM_AGE_MS = 30 * 60 * 1e3;
|
|
54
54
|
var HANG_COOLDOWN_MS = 10 * 60 * 1e3;
|
|
55
55
|
var UNAVAILABLE_COOLDOWN_MS = 60 * 1e3;
|
|
56
|
+
var PROVIDER_DEGRADED_COOLDOWN_MS = 30 * 60 * 1e3;
|
|
57
|
+
var PROVIDER_DEGRADED_WINDOW_MS = 10 * 60 * 1e3;
|
|
56
58
|
var COOLDOWN_BASE_MS = 60 * 1e3;
|
|
57
59
|
var COOLDOWN_MAX_MS = 15 * 60 * 1e3;
|
|
58
60
|
var QUOTA_COOLDOWN_BASE_MS = 15 * 60 * 1e3;
|
package/dist/pool.js
CHANGED
|
@@ -37,6 +37,12 @@ var MAX_CLAIM_AGE_MS = 30 * 60 * 1e3;
|
|
|
37
37
|
var HANG_COOLDOWN_MS = 10 * 60 * 1e3;
|
|
38
38
|
var UNAVAILABLE_COOLDOWN_MS = 60 * 1e3;
|
|
39
39
|
var UNAVAILABLE_REASON = "endpoint-unavailable";
|
|
40
|
+
var PROVIDER_DEGRADED_REASON = "provider-degraded";
|
|
41
|
+
var PROVIDER_DEGRADED_COOLDOWN_MS = 30 * 60 * 1e3;
|
|
42
|
+
var PROVIDER_DEGRADED_WINDOW_MS = 10 * 60 * 1e3;
|
|
43
|
+
var PROVIDER_DEGRADED_MIN_MODELS = 2;
|
|
44
|
+
var TRANSIENT_DEGRADATION_REASONS = /^(?:no answer|no-answer|hang|timeout|timed out|endpoint-unavailable|provider-degraded|empty response|network|server)$/i;
|
|
45
|
+
var FLAT_OUTAGE_REASONS = /* @__PURE__ */ new Set([UNAVAILABLE_REASON, PROVIDER_DEGRADED_REASON]);
|
|
40
46
|
var MAX_WEIGHT = 100;
|
|
41
47
|
var MIN_SLOT_CONTEXT = 1e5;
|
|
42
48
|
var MIN_SLOT_TPM = 2e4;
|
|
@@ -711,7 +717,7 @@ function readLimits(s) {
|
|
|
711
717
|
}
|
|
712
718
|
function recordStrike(s, model, reason, stated) {
|
|
713
719
|
if (!s.dirReady) return;
|
|
714
|
-
if (reason
|
|
720
|
+
if (FLAT_OUTAGE_REASONS.has(reason)) return coolUnavailable(s, model, stated, reason);
|
|
715
721
|
const now = Date.now();
|
|
716
722
|
const slotsForModel = s.slots.filter((candidate) => candidate.model === model);
|
|
717
723
|
const buckets = [...new Set(slotsForModel.map((slot) => slotBucket(slot)))];
|
|
@@ -742,13 +748,14 @@ function recordStrike(s, model, reason, stated) {
|
|
|
742
748
|
}
|
|
743
749
|
return strike;
|
|
744
750
|
}
|
|
745
|
-
function coolUnavailable(s, model) {
|
|
751
|
+
function coolUnavailable(s, model, windowMs = UNAVAILABLE_COOLDOWN_MS, reason = UNAVAILABLE_REASON) {
|
|
746
752
|
if (!s.dirReady) return;
|
|
747
753
|
const now = Date.now();
|
|
754
|
+
const requested = Number.isFinite(windowMs) && windowMs > 0 ? Math.min(windowMs, STATED_COOLDOWN_MAX_MS) : UNAVAILABLE_COOLDOWN_MS;
|
|
748
755
|
const prior = s.cooling.get(model);
|
|
749
|
-
const until = now +
|
|
756
|
+
const until = now + requested;
|
|
750
757
|
if (prior && prior.until > until) return prior;
|
|
751
|
-
const strike = { model, until, strikes: 1, reason
|
|
758
|
+
const strike = { model, until, strikes: 1, reason, last: now };
|
|
752
759
|
s.cooling.set(model, strike);
|
|
753
760
|
const path = join2(s.dir, limitFile(model));
|
|
754
761
|
try {
|
|
@@ -995,6 +1002,24 @@ function coolProvider(s, provider, until, reason) {
|
|
|
995
1002
|
}
|
|
996
1003
|
return affected;
|
|
997
1004
|
}
|
|
1005
|
+
function noteProviderDegradation(s, model, reason) {
|
|
1006
|
+
if (!s.dirReady) return [];
|
|
1007
|
+
if (!TRANSIENT_DEGRADATION_REASONS.test(String(reason))) return [];
|
|
1008
|
+
const provider = providerOf(model);
|
|
1009
|
+
const slots = (s.slots.length > 0 ? s.slots : FALLBACK_SLOTS).filter((slot) => providerOf(slot.model) === provider);
|
|
1010
|
+
if (slots.length < PROVIDER_DEGRADED_MIN_MODELS) return [];
|
|
1011
|
+
const now = Date.now();
|
|
1012
|
+
const failing = /* @__PURE__ */ new Set();
|
|
1013
|
+
for (const slot of slots) {
|
|
1014
|
+
const record = s.cooling.get(slot.model);
|
|
1015
|
+
if (!record || record.until <= now) continue;
|
|
1016
|
+
if (now - record.last > PROVIDER_DEGRADED_WINDOW_MS) continue;
|
|
1017
|
+
if (!TRANSIENT_DEGRADATION_REASONS.test(String(record.reason))) continue;
|
|
1018
|
+
failing.add(slot.model);
|
|
1019
|
+
}
|
|
1020
|
+
if (failing.size < PROVIDER_DEGRADED_MIN_MODELS) return [];
|
|
1021
|
+
return coolProvider(s, provider, now + PROVIDER_DEGRADED_COOLDOWN_MS, PROVIDER_DEGRADED_REASON);
|
|
1022
|
+
}
|
|
998
1023
|
function recordFailure(s, f) {
|
|
999
1024
|
try {
|
|
1000
1025
|
const path = join2(s.dir, "failures.jsonl");
|
|
@@ -1068,6 +1093,8 @@ export {
|
|
|
1068
1093
|
ORPHAN_TMP_MS,
|
|
1069
1094
|
PACKAGE_ROOT,
|
|
1070
1095
|
PRESETS_DIR,
|
|
1096
|
+
PROVIDER_DEGRADED_COOLDOWN_MS,
|
|
1097
|
+
PROVIDER_DEGRADED_REASON,
|
|
1071
1098
|
PROVIDER_LIST_TTL_MS,
|
|
1072
1099
|
QUOTA_COOLDOWN_BASE_MS,
|
|
1073
1100
|
QUOTA_COOLDOWN_MAX_MS,
|
|
@@ -1112,6 +1139,7 @@ export {
|
|
|
1112
1139
|
manualVariant,
|
|
1113
1140
|
markProbe,
|
|
1114
1141
|
noteActivity,
|
|
1142
|
+
noteProviderDegradation,
|
|
1115
1143
|
noteTpm,
|
|
1116
1144
|
notifyDefault,
|
|
1117
1145
|
parseRateLimitHeaders,
|
package/dist/tunnel.js
CHANGED
|
@@ -46,6 +46,8 @@ var STUCK_IDLE_MS = 3 * 60 * 1e3;
|
|
|
46
46
|
var MAX_CLAIM_AGE_MS = 30 * 60 * 1e3;
|
|
47
47
|
var HANG_COOLDOWN_MS = 10 * 60 * 1e3;
|
|
48
48
|
var UNAVAILABLE_COOLDOWN_MS = 60 * 1e3;
|
|
49
|
+
var PROVIDER_DEGRADED_COOLDOWN_MS = 30 * 60 * 1e3;
|
|
50
|
+
var PROVIDER_DEGRADED_WINDOW_MS = 10 * 60 * 1e3;
|
|
49
51
|
var COOLDOWN_BASE_MS = 60 * 1e3;
|
|
50
52
|
var COOLDOWN_MAX_MS = 15 * 60 * 1e3;
|
|
51
53
|
var QUOTA_COOLDOWN_BASE_MS = 15 * 60 * 1e3;
|
package/docs/USER-STORIES.md
CHANGED
|
@@ -91,6 +91,8 @@ The reconciliation with US-7 is that **acceptance and endorsement are different
|
|
|
91
91
|
- [ ] Given a spawn fails with a rate-limit-shaped error, When the error is serialized and matched, Then the offending model is marked cooling and a cooldown file is written for it.
|
|
92
92
|
- [ ] Given model A is cooling and another slot is usable, When the next spawn is routed, Then the pick is a different usable model, never the cooling one.
|
|
93
93
|
- [ ] Given a pooled task completes with an empty result, When the task hook returns, Then that model is benched for `empty response`, the result explains the bench, and the next spawn routes to another slot.
|
|
94
|
+
- [ ] Given two distinct models on one provider fail with transient, non-quota errors inside ten minutes, When the second failure is recorded, Then every slot on that provider is benched for one flat `provider-degraded` window and the next spawn routes to another provider.
|
|
95
|
+
- [ ] Given a provider-degraded window lapses, When a probe answers on one of its models, Then that model is re-admitted without a restart, because the bench was evidence about the provider, not a strike against the model.
|
|
94
96
|
- [ ] Given every model is cooling, When a spawn is routed, Then the spawn is refused explicitly with a message naming the cooling slots and their reasons, and no model is routed to.
|
|
95
97
|
- [ ] Given a limit error names a reset time, When the cooldown is recorded, Then the stated reset is used instead of a guessed backoff.
|
|
96
98
|
|
|
@@ -178,11 +180,26 @@ A daily scan was killed by a reboot mid-run, and because systemd records a trigg
|
|
|
178
180
|
**Acceptance criteria:**
|
|
179
181
|
- [ ] Given the installed timer, When it is rendered, Then it has `OnCalendar=*-*-* 00/6:00:00`, `OnStartupSec=5min`, and `Persistent=true`, and the service has `TimeoutStartSec=infinity`.
|
|
180
182
|
- [ ] Given a scan reaches its wall-clock budget, When the scores are written, Then unscanned and truncated providers keep their previous rows, so a partial run cannot shrink the pool.
|
|
181
|
-
- [ ] Given a model failed transiently (timeout, rate limit, credit), When selection runs, Then its last measured score is retained and a retry is scheduled
|
|
183
|
+
- [ ] Given a model failed transiently (timeout, rate limit, credit), When selection runs, Then its last measured score is retained and a retry is scheduled; the refresh rechecks within 5 minutes while anything failed, even when the stated reset is hours out.
|
|
182
184
|
- [ ] Given a permanent failure (401, 403 agreement, 404), When selection runs, Then the model is not retried and does not fall back to a previous score.
|
|
185
|
+
- [ ] Given three consecutive provider-level timeouts or network errors during a scan, When the breaker trips, Then the scan stops probing that provider, keeps its previous rows, and names the provider as degraded.
|
|
183
186
|
- [ ] Given enough other providers to fill the pool, When selection runs, Then exactly one `opencode` slot is reserved last with weight 2.
|
|
184
187
|
- [ ] Given two refreshes overlap, When the second starts, Then it skips while the lock is held, and a lock left by a dead PID is reclaimed.
|
|
185
188
|
|
|
189
|
+
### US-40: A degraded scan never shrinks the pool, and recovery is detected within minutes
|
|
190
|
+
**As a** user whose providers degrade for minutes to hours, **I want** the refresh to keep the working pool through the outage and restore it as soon as the degradation lifts, **so that** a transient outage never strands me on two slots and I never wait a full cycle for the recovery.
|
|
191
|
+
|
|
192
|
+
A refresh once replaced a healthy ten-slot pool with a two-slot scan because nothing stopped a degenerate selection from being written. The writer now refuses any selection below half the previous pool (minimum four slots) and keeps the previous file, and before scanning it checks the live opencode status: when opencode's serving path reports degraded and the pool is intact, the scan is skipped entirely and a recheck is scheduled in 5 minutes. The same 5-minute cadence applies after any failed run, so the moment the status clears — or a scan comes back full — the pool is restored on the next recheck instead of the next 6-hour slot.
|
|
193
|
+
|
|
194
|
+
**Story ID(s) for tests:** US-40
|
|
195
|
+
**Acceptance criteria:**
|
|
196
|
+
- [ ] Given a healthy pool and a scan that returns fewer slots than half of it (minimum four), When the refresh finishes, Then the previous pool file is left untouched, the refusal is logged, and a recheck is scheduled in 5 minutes.
|
|
197
|
+
- [ ] Given opencode's serving path reports degraded on the live status and the current pool is intact, When the refresh runs, Then no scan is started, the pool is kept, and a recheck is scheduled in 5 minutes.
|
|
198
|
+
- [ ] Given any failed run with a stated reset hours out, When the retry is scheduled, Then the recheck still happens within 5 minutes; a sooner provider reset is honoured.
|
|
199
|
+
- [ ] Given the degradation lifts, When the next 5-minute recheck runs, Then a full scan restores the pool instead of waiting for the next 6-hour slot.
|
|
200
|
+
- [ ] Given a thin or missing pool, When a fuller scan completes, Then it is always written — the guard only ever blocks shrinking, never recovery.
|
|
201
|
+
- [ ] Given the status endpoint is unreachable, When the refresh runs, Then the scan proceeds as before, because an unknown signal must never block a refresh and the write guard still protects the pool.
|
|
202
|
+
|
|
186
203
|
---
|
|
187
204
|
|
|
188
205
|
## Epic C — Hang detection and auto-recovery
|
|
@@ -603,6 +620,7 @@ table was filled in.
|
|
|
603
620
|
| US-28 | B | `tests/stories/i-omniroute.test.mjs` |
|
|
604
621
|
| US-31 | B | `tests/stories/b-provider-resilience.test.mjs`, `tests/stories/c-hang-recovery.test.mjs` |
|
|
605
622
|
| US-39 | B | `tests/stories/l-pool-refresh.test.mjs` |
|
|
623
|
+
| US-40 | B | `tests/stories/l-pool-refresh.test.mjs`, `tests/refresh-pool-test.mjs`, `tests/provider-status-test.mjs` |
|
|
606
624
|
| US-8 | C | `tests/stories/c-hang-recovery.test.mjs`, `tests/pool-hang-test.mjs` |
|
|
607
625
|
| US-9 | C | `tests/stories/c-hang-recovery.test.mjs`, `tests/pool-hang-test.mjs`, `tests/pool-shared-test.mjs` |
|
|
608
626
|
| US-10 | C | `tests/stories/c-hang-recovery.test.mjs`, `tests/pool-hang-test.mjs`, `tests/pool-timer-test.mjs` |
|
|
@@ -636,6 +654,6 @@ The root-level suites under `tests/` are unit-style; the `tests/stories/` suites
|
|
|
636
654
|
the behavioural layer, written one epic per file with every test name carrying
|
|
637
655
|
its story id. Run everything with `npm test`.
|
|
638
656
|
|
|
639
|
-
|
|
657
|
+
40/40 stories covered by 375 test case(s); thinnest story has 1.
|
|
640
658
|
No story is untested. The scan covers the `tests/stories/` suites and the root-level
|
|
641
659
|
`tests/*-test.mjs` suites.
|
package/package.json
CHANGED
|
@@ -251,6 +251,9 @@ export function selectPool({
|
|
|
251
251
|
continue
|
|
252
252
|
}
|
|
253
253
|
const entryRows = Array.isArray(entry?.rows) ? entry.rows : []
|
|
254
|
+
if (entry?.degraded) {
|
|
255
|
+
warnings.push(`${entry.provider}: degraded — stopped after consecutive transient failures; previous rows kept`)
|
|
256
|
+
}
|
|
254
257
|
if (entryRows.length === 0 || entryRows.some(rankable)) continue
|
|
255
258
|
const kinds = new Map()
|
|
256
259
|
for (const row of entryRows) {
|
package/scripts/refresh-pool.mjs
CHANGED
|
@@ -23,6 +23,7 @@ import { execFileSync } from "node:child_process"
|
|
|
23
23
|
import { homedir, userInfo } from "node:os"
|
|
24
24
|
import { dirname, join, resolve } from "node:path"
|
|
25
25
|
import { fileURLToPath } from "node:url"
|
|
26
|
+
import { fetchProviderStatus } from "./provider-status.mjs"
|
|
26
27
|
|
|
27
28
|
const HOME = homedir()
|
|
28
29
|
export const DEFAULT_SCORES_PATH = join(HOME, ".config", "lacode", "model-scores.json")
|
|
@@ -32,9 +33,21 @@ export const STABLE_LIB_DIR = (home = HOME) => join(home, ".local", "lib", "laco
|
|
|
32
33
|
export const STABLE_SCRIPT = (home = HOME) => join(STABLE_LIB_DIR(home), "refresh-pool.mjs")
|
|
33
34
|
export const STABLE_SCORER = (home = HOME) => join(STABLE_LIB_DIR(home), "score-models.mjs")
|
|
34
35
|
export const STABLE_SELECTION = (home = HOME) => join(STABLE_LIB_DIR(home), "pool-selection.mjs")
|
|
36
|
+
export const STABLE_STATUS = (home = HOME) => join(STABLE_LIB_DIR(home), "provider-status.mjs")
|
|
35
37
|
export const REFRESH_SERVICE_NAME = "lacode-refresh-pool.service"
|
|
36
38
|
export const REFRESH_TIMER_NAME = "lacode-refresh-pool.timer"
|
|
37
39
|
|
|
40
|
+
/**
|
|
41
|
+
* A refresh must never replace a working pool with a degenerate one. When the
|
|
42
|
+
* new selection falls below the keep threshold, the previous pool.txt is left
|
|
43
|
+
* untouched, the failure is logged loudly, and a fast retry is scheduled.
|
|
44
|
+
* The threshold is half the previous pool (rounded down), with a floor of
|
|
45
|
+
* MIN_POOL_SLOTS so a thin-but-real pool is still protected.
|
|
46
|
+
*/
|
|
47
|
+
export const MIN_POOL_SLOTS = 4
|
|
48
|
+
/** While degraded, recheck on this cadence instead of waiting out a long reset. */
|
|
49
|
+
export const DEGRADED_RETRY_S = 5 * 60
|
|
50
|
+
|
|
38
51
|
const REFRESH_LOCK_DIR = join(HOME, ".config", "lacode", "refresh.lock")
|
|
39
52
|
const REFRESH_LOCK_STALE_MS = 60 * 60 * 1000
|
|
40
53
|
|
|
@@ -189,18 +202,56 @@ function runScorer(max) {
|
|
|
189
202
|
execFileSync(process.execPath, args, { stdio: "inherit" })
|
|
190
203
|
}
|
|
191
204
|
|
|
192
|
-
|
|
205
|
+
/**
|
|
206
|
+
* Count the usable model slots in a pool.txt text (comments and blanks ignored).
|
|
207
|
+
*/
|
|
208
|
+
export function poolSlotCount(text) {
|
|
209
|
+
return splitHeaderAndSlots(text).slots.filter(isSlotLine).length
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Pure fail-closed guard. A refresh must never replace a working pool with a
|
|
214
|
+
* degenerate selection: when the new slot count falls below half the previous
|
|
215
|
+
* pool (floored at MIN_POOL_SLOTS), the previous pool is kept. There is no
|
|
216
|
+
* previous pool on a first-ever run, so there is nothing to protect and the
|
|
217
|
+
* guard passes.
|
|
218
|
+
*/
|
|
219
|
+
export function shouldKeepPool({ currentPoolText, newSlotCount }) {
|
|
220
|
+
const prev = poolSlotCount(currentPoolText ?? "")
|
|
221
|
+
if (prev <= 0) return { keep: false, prev, threshold: 0, reason: "no previous pool to protect" }
|
|
222
|
+
const threshold = Math.max(MIN_POOL_SLOTS, Math.floor(prev / 2))
|
|
223
|
+
if (newSlotCount >= threshold) return { keep: false, prev, threshold, reason: "" }
|
|
224
|
+
return {
|
|
225
|
+
keep: true,
|
|
226
|
+
prev,
|
|
227
|
+
threshold,
|
|
228
|
+
reason: `new selection has ${newSlotCount} usable slot(s), below the keep threshold of ${threshold} (previous pool had ${prev}); refusing to shrink the pool`,
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* How long until the next look while degraded. A provider reset in the future
|
|
234
|
+
* is honoured, but the wait never exceeds the degraded cadence: a reset hours
|
|
235
|
+
* out must not stop the 5-minute rechecks, and a reset that already passed
|
|
236
|
+
* means recheck on the cadence. No retry information at all also means the
|
|
237
|
+
* cadence.
|
|
238
|
+
*/
|
|
239
|
+
export function degradedRetryDelay(retry, now = Date.now()) {
|
|
193
240
|
const at = Date.parse(retry?.after ?? "")
|
|
194
|
-
if (!Number.isFinite(at)) return
|
|
195
|
-
const
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
241
|
+
if (!Number.isFinite(at)) return DEGRADED_RETRY_S
|
|
242
|
+
const wait = Math.ceil((at - now) / 1000)
|
|
243
|
+
if (wait <= 0) return DEGRADED_RETRY_S
|
|
244
|
+
return Math.min(wait, DEGRADED_RETRY_S)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function scheduleRetryIn(seconds, options = {}) {
|
|
248
|
+
if (!Number.isFinite(seconds) || seconds <= 0) return { scheduled: false, reason: "delay is not in the future" }
|
|
249
|
+
if (seconds > 24 * 60 * 60) return { scheduled: false, reason: "delay is more than a day away" }
|
|
200
250
|
const exec = options.exec ?? execFileSync
|
|
201
251
|
const scriptPath = options.scriptPath ?? fileURLToPath(import.meta.url)
|
|
202
252
|
const nodePath = options.nodePath ?? process.execPath
|
|
203
253
|
const home = options.home ?? HOME
|
|
254
|
+
const nowMs = (options.now ?? Date.now)()
|
|
204
255
|
const path = `${home}/.opencode/bin:/usr/local/bin:/usr/bin:/bin`
|
|
205
256
|
const unit = `lacode-refresh-pool-retry-${Math.floor(nowMs / 1000).toString(36)}`
|
|
206
257
|
try {
|
|
@@ -218,12 +269,29 @@ export function scheduleRetry(retry, options = {}) {
|
|
|
218
269
|
],
|
|
219
270
|
{ stdio: "ignore" },
|
|
220
271
|
)
|
|
221
|
-
return { scheduled: true, seconds
|
|
272
|
+
return { scheduled: true, seconds }
|
|
222
273
|
} catch (error) {
|
|
223
274
|
return { scheduled: false, reason: String(error?.message ?? error) }
|
|
224
275
|
}
|
|
225
276
|
}
|
|
226
277
|
|
|
278
|
+
export function scheduleRetry(retry, options = {}) {
|
|
279
|
+
const at = Date.parse(retry?.after ?? "")
|
|
280
|
+
if (!Number.isFinite(at)) return { scheduled: false, reason: "no reset time" }
|
|
281
|
+
const nowMs = (options.now ?? Date.now)()
|
|
282
|
+
const seconds = Math.ceil((at - nowMs) / 1000)
|
|
283
|
+
if (!Number.isFinite(seconds) || seconds <= 0) return { scheduled: false, reason: "reset is not in the future" }
|
|
284
|
+
if (seconds > 24 * 60 * 60) return { scheduled: false, reason: "reset is more than a day away" }
|
|
285
|
+
const scheduled = scheduleRetryIn(seconds, options)
|
|
286
|
+
if (scheduled.scheduled) return { ...scheduled, after: retry.after }
|
|
287
|
+
return scheduled
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Schedule a recheck on the degraded cadence (default 5 minutes). */
|
|
291
|
+
export function scheduleRetrySoon(seconds = DEGRADED_RETRY_S, options = {}) {
|
|
292
|
+
return scheduleRetryIn(seconds, options)
|
|
293
|
+
}
|
|
294
|
+
|
|
227
295
|
export function renderTimerUnits(input = {}) {
|
|
228
296
|
const nodePath = input.nodePath || process.execPath
|
|
229
297
|
const scriptPath = input.scriptPath || fileURLToPath(import.meta.url)
|
|
@@ -271,6 +339,7 @@ function installTimerUnits(stable = false) {
|
|
|
271
339
|
copyFileSync(live, scriptPath)
|
|
272
340
|
copyFileSync(resolve(dirname(live), "score-models.mjs"), STABLE_SCORER(HOME))
|
|
273
341
|
copyFileSync(resolve(dirname(live), "pool-selection.mjs"), STABLE_SELECTION(HOME))
|
|
342
|
+
copyFileSync(resolve(dirname(live), "provider-status.mjs"), STABLE_STATUS(HOME))
|
|
274
343
|
}
|
|
275
344
|
mkdirSync(dirname(units.servicePath), { recursive: true })
|
|
276
345
|
writeFileAtomic(units.servicePath, units.service)
|
|
@@ -314,7 +383,15 @@ function usage() {
|
|
|
314
383
|
--max N models per provider passed to the scorer (default: 50)
|
|
315
384
|
--scores PATH scores file (default: ${DEFAULT_SCORES_PATH})
|
|
316
385
|
--pool PATH pool file to read/compare/write (default: ${DEFAULT_POOL_PATH})
|
|
317
|
-
-h, --help this message
|
|
386
|
+
-h, --help this message
|
|
387
|
+
|
|
388
|
+
Degradation behaviour: before scanning, the refresh checks
|
|
389
|
+
https://status.opencode.ai/api/status. When opencode's serving path reports
|
|
390
|
+
degraded and the current pool is intact, the scan is skipped, the pool is kept,
|
|
391
|
+
and a recheck is scheduled in 5 minutes. After a scan, a selection below half
|
|
392
|
+
the previous pool (minimum ${MIN_POOL_SLOTS} slots) is never written; the previous pool
|
|
393
|
+
is kept and a 5-minute recheck is scheduled. A retry is always rechecked
|
|
394
|
+
within 5 minutes while anything failed, even when the stated reset is hours out.`)
|
|
318
395
|
}
|
|
319
396
|
|
|
320
397
|
async function main() {
|
|
@@ -344,6 +421,37 @@ async function main() {
|
|
|
344
421
|
process.once("exit", releaseRefreshLock)
|
|
345
422
|
}
|
|
346
423
|
|
|
424
|
+
const currentPoolText = readTextFile(poolPath)
|
|
425
|
+
|
|
426
|
+
// Live degradation signal. When opencode's own serving path reports degraded
|
|
427
|
+
// and the current pool is intact, probing now can only shrink it: keep the
|
|
428
|
+
// pool untouched and recheck in 5 minutes. A thin pool is still scanned, so
|
|
429
|
+
// a first-ever run (or a previously clobbered one) can recover, and the
|
|
430
|
+
// write guard below protects it if the scan comes back degenerate.
|
|
431
|
+
let providerStatus = { ok: false, providers: [], services: [] }
|
|
432
|
+
if (!printOnly) {
|
|
433
|
+
try {
|
|
434
|
+
providerStatus = await fetchProviderStatus()
|
|
435
|
+
} catch (error) {
|
|
436
|
+
providerStatus = { ok: false, providers: [], services: [], reason: String(error?.message ?? error) }
|
|
437
|
+
}
|
|
438
|
+
if (providerStatus.ok && providerStatus.providers.length > 0 && poolSlotCount(currentPoolText) >= MIN_POOL_SLOTS) {
|
|
439
|
+
const detail = providerStatus.services.map((entry) => `${entry.service}=${entry.status}`).join(", ")
|
|
440
|
+
console.log(
|
|
441
|
+
`opencode status degraded (${detail}); keeping the current pool of ${poolSlotCount(currentPoolText)} slots and rechecking in ${DEGRADED_RETRY_S / 60} minutes`,
|
|
442
|
+
)
|
|
443
|
+
if (!noRetry) {
|
|
444
|
+
const scheduled = scheduleRetrySoon()
|
|
445
|
+
console.log(
|
|
446
|
+
scheduled.scheduled
|
|
447
|
+
? `scheduled degraded recheck in ${scheduled.seconds}s`
|
|
448
|
+
: `degraded recheck not scheduled: ${scheduled.reason}`,
|
|
449
|
+
)
|
|
450
|
+
}
|
|
451
|
+
return
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
347
455
|
if (!fromScores) {
|
|
348
456
|
try {
|
|
349
457
|
runScorer(max)
|
|
@@ -363,7 +471,6 @@ async function main() {
|
|
|
363
471
|
return
|
|
364
472
|
}
|
|
365
473
|
|
|
366
|
-
const currentPoolText = readTextFile(poolPath)
|
|
367
474
|
let built
|
|
368
475
|
try {
|
|
369
476
|
built = buildPool({ scores, currentPoolText, now: new Date() })
|
|
@@ -373,7 +480,7 @@ async function main() {
|
|
|
373
480
|
return
|
|
374
481
|
}
|
|
375
482
|
|
|
376
|
-
const oldSlotCount =
|
|
483
|
+
const oldSlotCount = poolSlotCount(currentPoolText)
|
|
377
484
|
const newSlotCount = built.slots.filter(isSlotLine).length
|
|
378
485
|
const dropped = scores.providers.flatMap((entry) => (Array.isArray(entry?.excluded) ? entry.excluded : []))
|
|
379
486
|
const summary = [
|
|
@@ -382,11 +489,30 @@ async function main() {
|
|
|
382
489
|
...(built.warnings.length ? [`warnings [${built.warnings.join("; ")}]`] : []),
|
|
383
490
|
].join(" | ")
|
|
384
491
|
|
|
385
|
-
|
|
386
|
-
|
|
492
|
+
// Fail closed on a degenerate selection: keep the previous pool, say so
|
|
493
|
+
// loudly, and recheck in 5 minutes instead of waiting out a long reset.
|
|
494
|
+
if (!printOnly) {
|
|
495
|
+
const kept = shouldKeepPool({ currentPoolText, newSlotCount })
|
|
496
|
+
if (kept.keep) {
|
|
497
|
+
console.error(`refresh refused (fail closed): ${kept.reason}`)
|
|
498
|
+
if (!noRetry) {
|
|
499
|
+
const scheduled = scheduleRetrySoon()
|
|
500
|
+
console.log(
|
|
501
|
+
scheduled.scheduled
|
|
502
|
+
? `scheduled degraded recheck in ${scheduled.seconds}s`
|
|
503
|
+
: `degraded recheck not scheduled: ${scheduled.reason}`,
|
|
504
|
+
)
|
|
505
|
+
}
|
|
506
|
+
process.exitCode = 1
|
|
507
|
+
return
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
if (!printOnly && !noRetry && scores.retry) {
|
|
512
|
+
const scheduled = scheduleRetrySoon(degradedRetryDelay(scores.retry))
|
|
387
513
|
if (scheduled.scheduled) {
|
|
388
|
-
console.log(`scheduled retry in ${scheduled.seconds}s for ${scores.retry.models.length} model(s)
|
|
389
|
-
} else
|
|
514
|
+
console.log(`scheduled retry in ${scheduled.seconds}s for ${scores.retry.models.length} model(s) (reset ${scores.retry.after})`)
|
|
515
|
+
} else {
|
|
390
516
|
console.log(`retry not scheduled: ${scheduled.reason}`)
|
|
391
517
|
}
|
|
392
518
|
}
|
package/scripts/score-models.mjs
CHANGED
|
@@ -68,6 +68,10 @@ const SLOW_MS = 8_000 // at or above this, speed scores 0
|
|
|
68
68
|
const PROMPT = "Reply with the single word: ok"
|
|
69
69
|
const MAX_TOKENS = 8
|
|
70
70
|
const PROBE_ATTEMPTS = 2 // one retry for a timeout or a dropped connection
|
|
71
|
+
// Three consecutive provider-level timeouts/network errors is enough evidence
|
|
72
|
+
// that the provider is degraded. Stop spending the scan budget on the rest of
|
|
73
|
+
// its catalogue; the previous rows are retained and a retry is scheduled.
|
|
74
|
+
const PROVIDER_BREAKER_AFTER = 3
|
|
71
75
|
/**
|
|
72
76
|
* Retry the same probe only when the next attempt has a chance. A known
|
|
73
77
|
* exhausted balance or a permanent 401/403/404 will not recover in 300ms; the
|
|
@@ -601,6 +605,7 @@ async function main() {
|
|
|
601
605
|
continue
|
|
602
606
|
}
|
|
603
607
|
const rows = []
|
|
608
|
+
let consecutiveTransient = 0
|
|
604
609
|
for (const candidate of candidates) {
|
|
605
610
|
if (Date.now() >= deadline) {
|
|
606
611
|
budgetHit = true
|
|
@@ -614,6 +619,9 @@ async function main() {
|
|
|
614
619
|
await sleep(delayMs)
|
|
615
620
|
outcome = await probe(candidate, entry.provider)
|
|
616
621
|
}
|
|
622
|
+
const kind = outcome.kind ?? classifyFailure({ status: outcome.status, reason: outcome.reason })
|
|
623
|
+
const providerFault = !outcome.ok && ["timeout", "network", "server"].includes(kind)
|
|
624
|
+
consecutiveTransient = providerFault ? consecutiveTransient + 1 : 0
|
|
617
625
|
const row = { provider: entry.provider, ...candidate, ...outcome, score: null }
|
|
618
626
|
row.score = score(row)
|
|
619
627
|
if (row.score && row.score.value < minScore) {
|
|
@@ -628,9 +636,17 @@ async function main() {
|
|
|
628
636
|
rows.push(row)
|
|
629
637
|
const verdict = outcome.ok ? `${outcome.ms}ms` : outcome.reason.slice(0, 60)
|
|
630
638
|
console.log(` ${pad(candidate.model.replace(`${entry.provider}/`, ""), 52)} ${padL(verdict, 16)}`)
|
|
639
|
+
if (consecutiveTransient >= PROVIDER_BREAKER_AFTER) {
|
|
640
|
+
entry.truncated = true
|
|
641
|
+
entry.degraded = true
|
|
642
|
+
console.log(
|
|
643
|
+
` provider degraded: ${consecutiveTransient} consecutive ${kind} failures; stopped ${entry.provider} after ${rows.length}/${candidates.length}`,
|
|
644
|
+
)
|
|
645
|
+
break
|
|
646
|
+
}
|
|
631
647
|
await sleep(delayMs)
|
|
632
648
|
}
|
|
633
|
-
results.push({ provider: entry.provider, error: null, rows, excluded: entry.excluded, truncated: !!entry.truncated })
|
|
649
|
+
results.push({ provider: entry.provider, error: null, rows, excluded: entry.excluded, truncated: !!entry.truncated, degraded: !!entry.degraded })
|
|
634
650
|
}
|
|
635
651
|
if (budgetHit) {
|
|
636
652
|
console.log(
|
|
@@ -733,6 +749,7 @@ async function main() {
|
|
|
733
749
|
provider: e.provider,
|
|
734
750
|
error: e.error,
|
|
735
751
|
truncated: !!e.truncated,
|
|
752
|
+
degraded: !!e.degraded,
|
|
736
753
|
excluded: e.excluded ?? [],
|
|
737
754
|
rows: e.rows.flatMap((r) => {
|
|
738
755
|
const current = {
|