@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.
@@ -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 selection = scores.selection.filter(
145
- (entry) =>
146
- entry &&
147
- typeof entry.model === "string" &&
148
- entry.model.includes("/") &&
149
- typeof entry.weight === "number" &&
150
- Number.isFinite(entry.weight) &&
151
- entry.weight > 0,
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
- * Count the usable model slots in a pool.txt text (comments and blanks ignored).
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
- export function poolSlotCount(text) {
210
- return splitHeaderAndSlots(text).slots.filter(isSlotLine).length
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
- /** The pool's structural contract: an opencode-go head and an opencode tail. */
214
- export function poolHasHead(text) {
215
- const slots = splitHeaderAndSlots(text).slots.filter(isSlotLine)
216
- return slots.length > 0 && slots[0].split("#")[0].trim().startsWith("opencode-go/")
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
- export function poolHasTail(text) {
220
- const slots = splitHeaderAndSlots(text).slots.filter(isSlotLine)
221
- if (slots.length === 0) return false
222
- const last = slots[slots.length - 1].split("#")[0].trim()
223
- return last.startsWith("opencode/") && /(^|\s)b:opencode(\s|$)/.test(last)
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 scan-skip decision. Only a hard `down` on a gating opencode service may
228
- * skip a scan — `degraded` never does, because models routinely answer while
229
- * the page says degraded. A structurally broken pool is never skipped either:
230
- * the scan is its repair path.
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 shouldSkipScan({ providerStatus, currentPoolText }) {
233
- if (!providerStatus?.ok || providerStatus.stale) {
234
- return { skip: false, reason: "no trustworthy live signal; scanning" }
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 down = Array.isArray(providerStatus.down) ? providerStatus.down : []
237
- if (down.length === 0) {
238
- return { skip: false, reason: "no hard outage on the live status; scanning" }
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
- if (!poolHasHead(currentPoolText) || !poolHasTail(currentPoolText)) {
241
- return { skip: false, reason: "pool structure already broken; scanning to repair it" }
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
- return {
244
- skip: true,
245
- reason: `opencode status reports down: ${down.join(", ")}; keeping the current pool and rechecking in ${DEGRADED_RETRY_S / 60} minutes`,
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
- if (poolHasHead(currentPoolText) && !slots.some((line) => String(line).split("#")[0].trim().startsWith("opencode-go/"))) {
265
- return {
266
- keep: true,
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 has no opencode tail slot while the previous pool has one; refusing to dock the tail`,
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 checks
452
- https://status.opencode.ai/api/status. Only a hard outage (a gating service
453
- reporting down) with a structurally intact pool skips the scan; a mere degraded
454
- never does, because models routinely answer while the page says degraded. After
455
- a scan, a selection that beheads the pool (no opencode-go head), docks the tail
456
- (no opencode slot), or falls below half the contract pool size (minimum ${MIN_POOL_SLOTS}
457
- slots) is never written; the previous pool is kept and a 5-minute recheck is
458
- scheduled. A retry is always rechecked within 5 minutes while anything failed,
459
- even when the stated reset is hours out.`)
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 degradation signal. Only a hard `down` on opencode's serving path may
493
- // skip the scan — and only when the current pool is structurally intact, so
494
- // a broken pool always gets its repair scan. `degraded` never skips: models
495
- // routinely answer while the page says degraded, and the write guard below
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 providerStatus = { ok: false, providers: [], services: [], down: [] }
498
- if (!printOnly) {
601
+ let deprioritized = []
602
+ if (!printOnly && !fromScores) {
499
603
  try {
500
- providerStatus = await fetchProviderStatus()
604
+ deprioritized = statusDeprioritizedProviders(await fetchProviderStatus())
501
605
  } catch (error) {
502
- providerStatus = { ok: false, providers: [], services: [], down: [], reason: String(error?.message ?? error) }
606
+ console.log(`live status unreadable (${String(error?.message ?? error)}); scanning in default order`)
503
607
  }
504
- const skip = shouldSkipScan({ providerStatus, currentPoolText })
505
- if (skip.skip) {
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
- runScorer(max)
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(", ")}]`,