@lawaty/lacode 2.3.0-beta.4 → 2.3.0-beta.6
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 +39 -0
- package/README.md +13 -5
- package/dist/agents.js +1 -0
- package/dist/commands.js +6 -3
- package/dist/index.js +421 -161
- package/dist/lacode.js +1 -0
- package/dist/pool.js +152 -68
- package/dist/tunnel.js +1 -0
- package/docs/CONFIGURATION.md +15 -4
- package/docs/USER-STORIES.md +19 -2
- package/package.json +1 -1
- package/presets/README.md +35 -16
- package/presets/free-tier.json +17 -9
- package/scripts/pool-selection.mjs +316 -13
- package/scripts/refresh-pool.mjs +211 -94
- package/scripts/score-models.mjs +123 -17
package/scripts/refresh-pool.mjs
CHANGED
|
@@ -20,11 +20,11 @@
|
|
|
20
20
|
|
|
21
21
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"
|
|
22
22
|
import { execFileSync } from "node:child_process"
|
|
23
|
-
import { homedir, userInfo } from "node:os"
|
|
23
|
+
import { homedir, tmpdir, 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"
|
|
27
|
-
import { POOL_SIZE } from "./pool-selection.mjs"
|
|
26
|
+
import { fetchProviderStatus, statusDeprioritizedProviders } from "./provider-status.mjs"
|
|
27
|
+
import { POOL_SIZE, isTransientFailure, parsePoolSlots, poolStructureValid } from "./pool-selection.mjs"
|
|
28
28
|
|
|
29
29
|
const HOME = homedir()
|
|
30
30
|
export const DEFAULT_SCORES_PATH = join(HOME, ".config", "lacode", "model-scores.json")
|
|
@@ -60,6 +60,11 @@ const REFRESH_LOCK_STALE_MS = 60 * 60 * 1000
|
|
|
60
60
|
*/
|
|
61
61
|
function acquireRefreshLock() {
|
|
62
62
|
const take = () => {
|
|
63
|
+
// Ensure the parent exists (a fresh HOME, or the stable systemd copy before
|
|
64
|
+
// the plugin has ever started) but keep the lock directory's own creation
|
|
65
|
+
// non-recursive: mkdir with `recursive` succeeds on an existing directory,
|
|
66
|
+
// which would let two refreshes run at once.
|
|
67
|
+
mkdirSync(dirname(REFRESH_LOCK_DIR), { recursive: true })
|
|
63
68
|
mkdirSync(REFRESH_LOCK_DIR)
|
|
64
69
|
writeFileSync(join(REFRESH_LOCK_DIR, "pid"), String(process.pid))
|
|
65
70
|
return true
|
|
@@ -99,6 +104,9 @@ const isSlotLine = (line) => {
|
|
|
99
104
|
return stripped.length > 0 && stripped.includes("/")
|
|
100
105
|
}
|
|
101
106
|
|
|
107
|
+
/** The marker that starts the fallback section; shared with src/lib/pool.ts. */
|
|
108
|
+
export const FALLBACK_MARKER = "# --- fallback ---"
|
|
109
|
+
|
|
102
110
|
function splitHeaderAndSlots(text) {
|
|
103
111
|
const lines = String(text ?? "").split(/\r?\n/)
|
|
104
112
|
// Drop a single trailing empty element produced by a final newline so we can
|
|
@@ -120,11 +128,17 @@ function refreshComment(now, scoresGeneratedAt) {
|
|
|
120
128
|
|
|
121
129
|
function withRefreshComment(headerLines, comment) {
|
|
122
130
|
const kept = headerLines.filter((line) => !/^#\s*Refreshed\b/.test(String(line)))
|
|
131
|
+
// The header includes the blank separator before the slots, so trim trailing
|
|
132
|
+
// blanks or every run would prepend one more and the file would grow an
|
|
133
|
+
// unbounded run of empty lines.
|
|
134
|
+
while (kept.length > 0 && String(kept[kept.length - 1]).trim() === "") kept.pop()
|
|
123
135
|
return [...kept, comment]
|
|
124
136
|
}
|
|
125
137
|
|
|
126
|
-
function formatGeneratedLine(model, weight, bucket, context) {
|
|
138
|
+
function formatGeneratedLine(model, weight, bucket, context, ceiling) {
|
|
127
139
|
const parts = [String(model), String(weight)]
|
|
140
|
+
// A ceiling equal to the weight is the default (one pass), so it is not printed.
|
|
141
|
+
if (Number.isFinite(Number(ceiling)) && Number(ceiling) > Number(weight)) parts.push(`ceil:${Math.round(Number(ceiling))}`)
|
|
128
142
|
if (context != null && Number.isFinite(Number(context))) parts.push(`c:${Math.round(Number(context))}`)
|
|
129
143
|
if (bucket) parts.push(`b:${bucket}`)
|
|
130
144
|
return parts.join(" ")
|
|
@@ -141,29 +155,38 @@ export function buildPool(input) {
|
|
|
141
155
|
if (!scores || typeof scores !== "object" || !Array.isArray(scores.providers) || !Array.isArray(scores.selection)) {
|
|
142
156
|
throw new Error("score file is missing or invalid (expected { providers: [...], selection: [...] })")
|
|
143
157
|
}
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
)
|
|
158
|
+
const validSelection = (entry) =>
|
|
159
|
+
entry &&
|
|
160
|
+
typeof entry.model === "string" &&
|
|
161
|
+
entry.model.includes("/") &&
|
|
162
|
+
typeof entry.weight === "number" &&
|
|
163
|
+
Number.isFinite(entry.weight) &&
|
|
164
|
+
entry.weight > 0
|
|
165
|
+
const selection = scores.selection.filter(validSelection)
|
|
153
166
|
if (selection.length === 0) {
|
|
154
167
|
throw new Error("score file has no selected models; refusing to write pool.txt")
|
|
155
168
|
}
|
|
169
|
+
// A score file written before fallback/ceiling support has no such fields.
|
|
170
|
+
// Treat "absent" as "no opinion" and carry the configured facts forward, so a
|
|
171
|
+
// refresh cannot silently delete a hand-written fallback or ceiling.
|
|
172
|
+
const currentSlots = parsePoolSlots(input?.currentPoolText ?? "")
|
|
173
|
+
const currentByModel = new Map(currentSlots.map((slot) => [slot.model, slot]))
|
|
174
|
+
const fallbackProvided = Array.isArray(scores.fallback)
|
|
175
|
+
const fallback = (fallbackProvided ? scores.fallback : currentSlots.filter((slot) => slot.fallback)).filter(validSelection)
|
|
176
|
+
const ceilingOf = (entry) => entry.ceiling ?? currentByModel.get(entry.model)?.ceiling
|
|
156
177
|
const warnings = Array.isArray(scores.warnings) ? scores.warnings : []
|
|
157
178
|
const { header } = splitHeaderAndSlots(input?.currentPoolText ?? "")
|
|
158
|
-
const outSlots = selection.map((entry) => formatGeneratedLine(entry.model, entry.weight, entry.bucket, entry.context))
|
|
179
|
+
const outSlots = selection.map((entry) => formatGeneratedLine(entry.model, entry.weight, entry.bucket, entry.context, ceilingOf(entry)))
|
|
180
|
+
const outFallback = fallback.map((entry) => formatGeneratedLine(entry.model, entry.weight, entry.bucket, entry.context, ceilingOf(entry)))
|
|
159
181
|
const comment = refreshComment(input?.now ?? new Date(), scores.generatedAt)
|
|
160
182
|
const outHeader = withRefreshComment(header, comment)
|
|
161
|
-
const text = [...outHeader, "", ...outSlots, ""].join("\n")
|
|
183
|
+
const text = [...outHeader, "", ...outSlots, ...(outFallback.length > 0 ? ["", FALLBACK_MARKER, ...outFallback] : []), ""].join("\n")
|
|
162
184
|
return {
|
|
163
185
|
text,
|
|
164
186
|
slots: outSlots,
|
|
187
|
+
fallback: outFallback,
|
|
165
188
|
warnings,
|
|
166
|
-
chosen: { selected: selection.map((entry) => entry.model) },
|
|
189
|
+
chosen: { selected: selection.map((entry) => entry.model), fallback: fallback.map((entry) => entry.model) },
|
|
167
190
|
}
|
|
168
191
|
}
|
|
169
192
|
|
|
@@ -195,55 +218,138 @@ function writeFileAtomic(path, text) {
|
|
|
195
218
|
renameSync(tmp, path)
|
|
196
219
|
}
|
|
197
220
|
|
|
198
|
-
function runScorer(max) {
|
|
221
|
+
function runScorer(max, extraArgs = []) {
|
|
199
222
|
const here = dirname(fileURLToPath(import.meta.url))
|
|
200
223
|
const scorer = resolve(here, "score-models.mjs")
|
|
201
224
|
if (!existsSync(scorer)) throw new Error(`scorer not found: ${scorer}`)
|
|
202
|
-
const args = [scorer, "--max", String(max)]
|
|
225
|
+
const args = [scorer, "--max", String(max), ...extraArgs]
|
|
203
226
|
execFileSync(process.execPath, args, { stdio: "inherit" })
|
|
204
227
|
}
|
|
205
228
|
|
|
206
229
|
/**
|
|
207
|
-
*
|
|
230
|
+
* Cheap health check: probe exactly the current pool members (no catalogue
|
|
231
|
+
* sweep). Every member answers → the pool stands as-is: no full scan, no
|
|
232
|
+
* retry, back to the 6-hour cadence. Anything else → false, and the caller
|
|
233
|
+
* runs a full scan to verify. Returns true when the pool stands.
|
|
208
234
|
*/
|
|
209
|
-
|
|
210
|
-
|
|
235
|
+
function runCanary({ currentPoolText, scorerArgs, max }) {
|
|
236
|
+
const members = parsePoolSlots(currentPoolText).map((slot) => slot.model)
|
|
237
|
+
if (members.length === 0) return false
|
|
238
|
+
const tmp = join(tmpdir(), `lacode-canary-${process.pid}.json`)
|
|
239
|
+
try {
|
|
240
|
+
runScorer(Math.max(max, members.length), [...scorerArgs, "--probe-models", members.join(","), "--out", tmp])
|
|
241
|
+
const payload = JSON.parse(readFileSync(tmp, "utf8"))
|
|
242
|
+
const answered = new Set()
|
|
243
|
+
for (const entry of payload?.providers ?? []) {
|
|
244
|
+
for (const row of entry?.rows ?? []) {
|
|
245
|
+
if (row?.model && row?.ok === true) answered.add(row.model)
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
const missing = members.filter((model) => !answered.has(model))
|
|
249
|
+
if (missing.length === 0) {
|
|
250
|
+
console.log(`canary healthy: all ${members.length} pool member(s) answered; pool stands, no full scan`)
|
|
251
|
+
return true
|
|
252
|
+
}
|
|
253
|
+
console.log(`canary: ${missing.length} member(s) not answering (${missing.join(", ")}); running a full scan`)
|
|
254
|
+
return false
|
|
255
|
+
} finally {
|
|
256
|
+
try {
|
|
257
|
+
rmSync(tmp, { force: true })
|
|
258
|
+
} catch {}
|
|
259
|
+
}
|
|
211
260
|
}
|
|
212
261
|
|
|
213
|
-
/**
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
262
|
+
/**
|
|
263
|
+
* Count the usable model slots in a pool.txt text (comments and blanks ignored).
|
|
264
|
+
*/
|
|
265
|
+
export function poolSlotCount(text) {
|
|
266
|
+
return parsePoolSlots(text).filter((slot) => !slot.fallback).length
|
|
217
267
|
}
|
|
218
268
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
269
|
+
/**
|
|
270
|
+
* Bench-in-place file layout. Must match src/lib/pool.ts: the runtime treats
|
|
271
|
+
* these files as flat cooling records (never strikes) and re-admits a model
|
|
272
|
+
* the moment a probe watches it answer.
|
|
273
|
+
*/
|
|
274
|
+
export const SCAN_BENCH_REASON = "scan-kept"
|
|
275
|
+
// Bench a kept-but-failing member until the next scheduled refresh. The timer
|
|
276
|
+
// runs every six hours; a 45-minute bench left the slot routable for most of the
|
|
277
|
+
// cycle, so it kept failing spawns between refreshes. The cooldown probe still
|
|
278
|
+
// re-admits the model as soon as it watches it answer, so a real recovery is not
|
|
279
|
+
// made to wait for the timer.
|
|
280
|
+
export const SCAN_BENCH_WINDOW_MS = 6 * 60 * 60 * 1000
|
|
281
|
+
export const scanBenchDir = (home = HOME) => join(home, ".local", "share", "opencode", "agent-pool")
|
|
282
|
+
export const scanBenchFile = (model) => `limit.${String(model).replace(/[^a-zA-Z0-9._-]/g, "__")}.json`
|
|
225
283
|
|
|
226
284
|
/**
|
|
227
|
-
* Pure
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
285
|
+
* Pure bench plan for the current pool members. A member whose current row
|
|
286
|
+
* failed transiently is benched until its stated reset (else until the next
|
|
287
|
+
* scheduled refresh); a member that answered has any stale bench cleared; an
|
|
288
|
+
* unscanned member is left alone for lack of evidence. Only pool members are
|
|
289
|
+
* ever touched.
|
|
231
290
|
*/
|
|
232
|
-
export function
|
|
233
|
-
|
|
234
|
-
|
|
291
|
+
export function computeScanBench({ selectionModels, providers, now = Date.now() }) {
|
|
292
|
+
const currentByModel = new Map()
|
|
293
|
+
for (const entry of Array.isArray(providers) ? providers : []) {
|
|
294
|
+
for (const row of Array.isArray(entry?.rows) ? entry.rows : []) {
|
|
295
|
+
if (row?.model && !currentByModel.has(row.model)) currentByModel.set(row.model, row)
|
|
296
|
+
}
|
|
235
297
|
}
|
|
236
|
-
const
|
|
237
|
-
|
|
238
|
-
|
|
298
|
+
const bench = []
|
|
299
|
+
const clear = []
|
|
300
|
+
for (const model of selectionModels ?? []) {
|
|
301
|
+
const row = currentByModel.get(model)
|
|
302
|
+
if (!row) continue
|
|
303
|
+
if (row.ok !== false) {
|
|
304
|
+
clear.push(model)
|
|
305
|
+
continue
|
|
306
|
+
}
|
|
307
|
+
if (!isTransientFailure(row)) continue
|
|
308
|
+
const at = Number(row.retryAfter)
|
|
309
|
+
bench.push({
|
|
310
|
+
model,
|
|
311
|
+
until: Number.isFinite(at) && at > now ? at : now + SCAN_BENCH_WINDOW_MS,
|
|
312
|
+
kind: String(row.kind ?? "unknown"),
|
|
313
|
+
})
|
|
239
314
|
}
|
|
240
|
-
|
|
241
|
-
|
|
315
|
+
return { bench, clear }
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Apply a bench plan with atomic writes; every failure is best-effort. */
|
|
319
|
+
export function applyScanBench({ dir, bench, clear }) {
|
|
320
|
+
const applied = { benched: [], cleared: [] }
|
|
321
|
+
try {
|
|
322
|
+
mkdirSync(dir, { recursive: true })
|
|
323
|
+
} catch {}
|
|
324
|
+
for (const { model, until } of bench ?? []) {
|
|
325
|
+
try {
|
|
326
|
+
const record = { model, until, strikes: 1, reason: SCAN_BENCH_REASON, last: Date.now() }
|
|
327
|
+
const tmp = join(dir, `${scanBenchFile(model)}.tmp-${process.pid}`)
|
|
328
|
+
writeFileSync(tmp, JSON.stringify(record))
|
|
329
|
+
renameSync(tmp, join(dir, scanBenchFile(model)))
|
|
330
|
+
applied.benched.push(model)
|
|
331
|
+
} catch {}
|
|
242
332
|
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
333
|
+
for (const model of clear ?? []) {
|
|
334
|
+
const path = join(dir, scanBenchFile(model))
|
|
335
|
+
try {
|
|
336
|
+
// Clear only the bench THIS scan wrote. A runtime strike
|
|
337
|
+
// (`rate-limited`, `no answer`, `provider error`, ...) is evidence from a
|
|
338
|
+
// real spawn and carries the escalation count; deleting it because a
|
|
339
|
+
// probe happened to answer would let a model that fails every real
|
|
340
|
+
// request be re-admitted every refresh. The runtime owns that record.
|
|
341
|
+
let reason
|
|
342
|
+
try {
|
|
343
|
+
reason = JSON.parse(readFileSync(path, "utf8"))?.reason
|
|
344
|
+
} catch {
|
|
345
|
+
continue // nothing on disk to clear
|
|
346
|
+
}
|
|
347
|
+
if (reason !== SCAN_BENCH_REASON) continue
|
|
348
|
+
rmSync(path, { force: true })
|
|
349
|
+
applied.cleared.push(model)
|
|
350
|
+
} catch {}
|
|
246
351
|
}
|
|
352
|
+
return applied
|
|
247
353
|
}
|
|
248
354
|
|
|
249
355
|
/**
|
|
@@ -261,32 +367,22 @@ export function shouldKeepPool({ currentPoolText, newSlots }) {
|
|
|
261
367
|
if (prev <= 0) return { keep: false, prev, threshold: 0, reason: "no previous pool to protect" }
|
|
262
368
|
const slots = Array.isArray(newSlots) ? newSlots : []
|
|
263
369
|
const threshold = Math.max(MIN_POOL_SLOTS, Math.floor(POOL_SIZE / 2))
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
prev,
|
|
268
|
-
threshold,
|
|
269
|
-
reason: `new selection has no opencode-go head slot while the previous pool has one; refusing to behead the pool`,
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
const tailKept = slots.some((line) => {
|
|
273
|
-
const stripped = String(line).split("#")[0].trim()
|
|
274
|
-
return stripped.startsWith("opencode/") && /(^|\s)b:opencode(\s|$)/.test(stripped)
|
|
275
|
-
})
|
|
276
|
-
if (poolHasTail(currentPoolText) && !tailKept) {
|
|
370
|
+
const currentCheck = poolStructureValid(currentPoolText)
|
|
371
|
+
const newCheck = poolStructureValid(slots)
|
|
372
|
+
if (currentCheck.valid && !newCheck.valid) {
|
|
277
373
|
return {
|
|
278
374
|
keep: true,
|
|
279
375
|
prev,
|
|
280
376
|
threshold,
|
|
281
|
-
reason: `new selection
|
|
377
|
+
reason: `new selection breaks the pool structure (${newCheck.problems.join("; ")}); keeping the previous pool`,
|
|
282
378
|
}
|
|
283
379
|
}
|
|
284
|
-
if (slots.length >= threshold) return { keep: false, prev, threshold, reason: "" }
|
|
380
|
+
if (parsePoolSlots(slots.join("\n")).filter((slot) => !slot.fallback).length >= threshold) return { keep: false, prev, threshold, reason: "" }
|
|
285
381
|
return {
|
|
286
382
|
keep: true,
|
|
287
383
|
prev,
|
|
288
384
|
threshold,
|
|
289
|
-
reason: `new selection has ${slots.length} usable slot(s), below the keep threshold of ${threshold} (contract pool size ${POOL_SIZE}); refusing to shrink the pool`,
|
|
385
|
+
reason: `new selection has ${slots.filter(isSlotLine).length} usable slot(s), below the keep threshold of ${threshold} (contract pool size ${POOL_SIZE}); refusing to shrink the pool`,
|
|
290
386
|
}
|
|
291
387
|
}
|
|
292
388
|
|
|
@@ -312,6 +408,7 @@ export function scheduleRetryIn(seconds, options = {}) {
|
|
|
312
408
|
const scriptPath = options.scriptPath ?? fileURLToPath(import.meta.url)
|
|
313
409
|
const nodePath = options.nodePath ?? process.execPath
|
|
314
410
|
const home = options.home ?? HOME
|
|
411
|
+
const extraArgs = Array.isArray(options.args) ? options.args : []
|
|
315
412
|
const nowMs = (options.now ?? Date.now)()
|
|
316
413
|
const path = `${home}/.opencode/bin:/usr/local/bin:/usr/bin:/bin`
|
|
317
414
|
const unit = `lacode-refresh-pool-retry-${Math.floor(nowMs / 1000).toString(36)}`
|
|
@@ -327,6 +424,7 @@ export function scheduleRetryIn(seconds, options = {}) {
|
|
|
327
424
|
`--setenv=PATH=${path}`,
|
|
328
425
|
nodePath,
|
|
329
426
|
scriptPath,
|
|
427
|
+
...extraArgs,
|
|
330
428
|
],
|
|
331
429
|
{ stdio: "ignore" },
|
|
332
430
|
)
|
|
@@ -348,9 +446,11 @@ export function scheduleRetry(retry, options = {}) {
|
|
|
348
446
|
return scheduled
|
|
349
447
|
}
|
|
350
448
|
|
|
351
|
-
/** Schedule a recheck on the degraded cadence (default 5 minutes).
|
|
449
|
+
/** Schedule a recheck on the degraded cadence (default 5 minutes). Retries run
|
|
450
|
+
* a cheap canary first and only fall back to a full scan when a pool member
|
|
451
|
+
* stops answering. */
|
|
352
452
|
export function scheduleRetrySoon(seconds = DEGRADED_RETRY_S, options = {}) {
|
|
353
|
-
return scheduleRetryIn(seconds, options)
|
|
453
|
+
return scheduleRetryIn(seconds, { ...options, args: [...(options.args ?? []), "--canary"] })
|
|
354
454
|
}
|
|
355
455
|
|
|
356
456
|
export function renderTimerUnits(input = {}) {
|
|
@@ -441,6 +541,8 @@ function usage() {
|
|
|
441
541
|
systemctl --user start lacode-refresh-pool.service
|
|
442
542
|
--print print the proposed pool.txt and make no changes (alias: --dry-run)
|
|
443
543
|
--dry-run same as --print
|
|
544
|
+
--canary probe only the current pool members; run a full scan only
|
|
545
|
+
when one stops answering (used by degraded rechecks)
|
|
444
546
|
--max N models per provider passed to the scorer (default: 50)
|
|
445
547
|
--scores PATH scores file (default: ${DEFAULT_SCORES_PATH})
|
|
446
548
|
--pool PATH pool file to read/compare/write (default: ${DEFAULT_POOL_PATH})
|
|
@@ -448,15 +550,16 @@ function usage() {
|
|
|
448
550
|
keep the previous pool (human override for a changed catalogue)
|
|
449
551
|
-h, --help this message
|
|
450
552
|
|
|
451
|
-
Degradation behaviour: before scanning, the refresh
|
|
452
|
-
https://status.opencode.ai/api/status.
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
slots) is never written; the
|
|
458
|
-
|
|
459
|
-
|
|
553
|
+
Degradation behaviour: before scanning, the refresh reads
|
|
554
|
+
https://status.opencode.ai/api/status. The page is a hint, never a verdict:
|
|
555
|
+
sick providers are only probed last, and nothing is ever skipped or benched
|
|
556
|
+
because of it. A canary (--canary, used by every degraded recheck) probes only
|
|
557
|
+
the current pool members and runs a full scan solely when one stops answering.
|
|
558
|
+
After a scan, a selection that breaks the pool structure or falls below half
|
|
559
|
+
the contract pool size (minimum ${MIN_POOL_SLOTS} slots) is never written; the
|
|
560
|
+
previous pool is kept, transiently failed members are still benched in place,
|
|
561
|
+
and a 5-minute canary recheck is scheduled. A retry is always rechecked
|
|
562
|
+
within 5 minutes while anything failed, even when the stated reset is hours out.`)
|
|
460
563
|
}
|
|
461
564
|
|
|
462
565
|
async function main() {
|
|
@@ -488,37 +591,36 @@ async function main() {
|
|
|
488
591
|
}
|
|
489
592
|
|
|
490
593
|
const currentPoolText = readTextFile(poolPath)
|
|
594
|
+
const canary = argv.includes("--canary")
|
|
491
595
|
|
|
492
|
-
// Live
|
|
493
|
-
//
|
|
494
|
-
//
|
|
495
|
-
//
|
|
596
|
+
// Live status is a hint, never a verdict. Gating services that report down
|
|
597
|
+
// or degraded only reorder probes (sick providers last) so they cannot eat
|
|
598
|
+
// the scan budget. Nothing is ever skipped or benched because of the page —
|
|
599
|
+
// only real probe evidence benches a model, and the write guard below
|
|
496
600
|
// protects the pool if the scan comes back degenerate.
|
|
497
|
-
let
|
|
498
|
-
if (!printOnly) {
|
|
601
|
+
let deprioritized = []
|
|
602
|
+
if (!printOnly && !fromScores) {
|
|
499
603
|
try {
|
|
500
|
-
|
|
604
|
+
deprioritized = statusDeprioritizedProviders(await fetchProviderStatus())
|
|
501
605
|
} catch (error) {
|
|
502
|
-
|
|
606
|
+
console.log(`live status unreadable (${String(error?.message ?? error)}); scanning in default order`)
|
|
503
607
|
}
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
console.log(`${skip.reason}; keeping the current pool of ${poolSlotCount(currentPoolText)} slots`)
|
|
507
|
-
if (!noRetry) {
|
|
508
|
-
const scheduled = scheduleRetrySoon()
|
|
509
|
-
console.log(
|
|
510
|
-
scheduled.scheduled
|
|
511
|
-
? `scheduled degraded recheck in ${scheduled.seconds}s`
|
|
512
|
-
: `degraded recheck not scheduled: ${scheduled.reason}`,
|
|
513
|
-
)
|
|
514
|
-
}
|
|
515
|
-
return
|
|
608
|
+
if (deprioritized.length > 0) {
|
|
609
|
+
console.log(`status hint: probing ${deprioritized.join(", ")} last`)
|
|
516
610
|
}
|
|
517
611
|
}
|
|
612
|
+
const scorerArgs = [`--current-pool`, poolPath]
|
|
613
|
+
if (deprioritized.length > 0) scorerArgs.push(`--deprioritize-providers`, deprioritized.join(","))
|
|
614
|
+
|
|
615
|
+
const runFullScan = () => runScorer(max, scorerArgs)
|
|
518
616
|
|
|
519
617
|
if (!fromScores) {
|
|
520
618
|
try {
|
|
521
|
-
|
|
619
|
+
if (canary) {
|
|
620
|
+
runCanary({ poolPath, currentPoolText, scorerArgs, max })
|
|
621
|
+
} else {
|
|
622
|
+
runFullScan()
|
|
623
|
+
}
|
|
522
624
|
} catch (error) {
|
|
523
625
|
console.error(`scorer failed; pool.txt not written: ${String(error?.message ?? error)}`)
|
|
524
626
|
if (!printOnly && !noRetry) {
|
|
@@ -562,6 +664,21 @@ async function main() {
|
|
|
562
664
|
|
|
563
665
|
const oldSlotCount = poolSlotCount(currentPoolText)
|
|
564
666
|
const newSlotCount = built.slots.filter(isSlotLine).length
|
|
667
|
+
|
|
668
|
+
// Bench-in-place: a pool member this run failed transiently keeps its slot
|
|
669
|
+
// and gets a flat cooling record the runtime already honours; a member that
|
|
670
|
+
// answered has any stale bench cleared. Unscanned members are left alone.
|
|
671
|
+
// Runs on the write path and the refusal path alike — a kept pool still
|
|
672
|
+
// learns what the scan found.
|
|
673
|
+
if (!printOnly) {
|
|
674
|
+
const plan = computeScanBench({ selectionModels: built.chosen.selected, providers: scores.providers })
|
|
675
|
+
const applied = applyScanBench({ dir: scanBenchDir(), bench: plan.bench, clear: plan.clear })
|
|
676
|
+
if (applied.benched.length > 0) {
|
|
677
|
+
const detail = plan.bench.map((entry) => `${entry.model} (${entry.kind})`).join(", ")
|
|
678
|
+
console.log(`scan benched in place: ${detail}`)
|
|
679
|
+
}
|
|
680
|
+
if (applied.cleared.length > 0) console.log(`scan cleared stale benches: ${applied.cleared.join(", ")}`)
|
|
681
|
+
}
|
|
565
682
|
const dropped = scores.providers.flatMap((entry) => (Array.isArray(entry?.excluded) ? entry.excluded : []))
|
|
566
683
|
const summary = [
|
|
567
684
|
`selected [${built.chosen.selected.join(", ")}]`,
|