@lawaty/lacode 2.3.0-beta.0 → 2.3.0-beta.1

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 CHANGED
@@ -9,6 +9,22 @@ commit as the changelog entry it describes.
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [2.3.0-beta.1] — provider degradation backoff
13
+
14
+ ### Added
15
+
16
+ - 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.
17
+ - 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.
18
+ - `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.
19
+
20
+ ### Changed
21
+
22
+ - 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.
23
+
24
+ ### Fixed
25
+
26
+ - Pool: repeated timeouts on a degraded provider no longer re-probe every model every few minutes or inflate each model's strike count.
27
+
12
28
  ## [2.3.0-beta.0] — reliable pool refresh, autonomy modes, and empty-response failover
13
29
 
14
30
  ### 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 === UNAVAILABLE_REASON) return coolUnavailable(s, model);
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 + UNAVAILABLE_COOLDOWN_MS;
756
+ const until = now + requested;
750
757
  if (prior && prior.until > until) return prior;
751
- const strike = { model, until, strikes: 1, reason: UNAVAILABLE_REASON, last: now };
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) await log2("warn", `cooling ${claim.m} for ${Math.round((strike.until - now) / 1e3)}s (hang)`);
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 === UNAVAILABLE_REASON) return coolUnavailable(s, model);
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 + UNAVAILABLE_COOLDOWN_MS;
756
+ const until = now + requested;
750
757
  if (prior && prior.until > until) return prior;
751
- const strike = { model, until, strikes: 1, reason: UNAVAILABLE_REASON, last: now };
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;
@@ -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
 
@@ -180,6 +182,7 @@ A daily scan was killed by a reboot mid-run, and because systemd records a trigg
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
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 at the stated reset or 45 minutes later.
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
 
@@ -636,6 +639,6 @@ The root-level suites under `tests/` are unit-style; the `tests/stories/` suites
636
639
  the behavioural layer, written one epic per file with every test name carrying
637
640
  its story id. Run everything with `npm test`.
638
641
 
639
- 39/39 stories covered by 370 test case(s); thinnest story has 1.
642
+ 39/39 stories covered by 372 test case(s); thinnest story has 1.
640
643
  No story is untested. The scan covers the `tests/stories/` suites and the root-level
641
644
  `tests/*-test.mjs` suites.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lawaty/lacode",
3
- "version": "2.3.0-beta.0",
3
+ "version": "2.3.0-beta.1",
4
4
  "description": "Ultimate opencode setup for Go users — cost-controlled pooled subagents, hang recovery, and notifications for opencode.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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) {
@@ -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 = {