@lawaty/lacode 2.3.0-beta.3 → 2.3.0-beta.4
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 +9 -0
- package/docs/USER-STORIES.md +4 -2
- package/package.json +1 -1
- package/scripts/refresh-pool.mjs +110 -29
- package/scripts/score-models.mjs +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,15 @@ commit as the changelog entry it describes.
|
|
|
9
9
|
|
|
10
10
|
## [Unreleased]
|
|
11
11
|
|
|
12
|
+
## [2.3.0-beta.4] — status never benches, structure is the contract
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- Pool: only a hard outage (a gating `go`/`free` service reporting `down`) with a structurally intact pool may skip a refresh scan. A mere `degraded` never skips, because models routinely answer while the status page says degraded. Paid-tier (`payg`) and roll-up (`inference`) components can no longer freeze the free pool.
|
|
17
|
+
- Pool: the fail-closed guard now enforces structure, not just count. A selection that drops the opencode-go head or the opencode tail is refused even at full count, and the keep threshold is anchored to the contract pool size (10) instead of the current file, so repeated writes can no longer ratchet the pool down. `--force-write` overrides the guard for a genuinely changed catalogue.
|
|
18
|
+
- Pool: per-request probe deadline is 12 seconds (was 45), so a flapping provider stretches a scan far less; `--timeout MS` still overrides it.
|
|
19
|
+
- Pool: a failed scorer run or a refused write now schedules a 5-minute recheck like every other failed run, instead of waiting for the next 6-hour slot.
|
|
20
|
+
|
|
12
21
|
## [2.3.0-beta.3] — persist autonomy modes in global config
|
|
13
22
|
|
|
14
23
|
### Changed
|
package/docs/USER-STORIES.md
CHANGED
|
@@ -194,7 +194,9 @@ A refresh once replaced a healthy ten-slot pool with a two-slot scan because not
|
|
|
194
194
|
**Story ID(s) for tests:** US-40
|
|
195
195
|
**Acceptance criteria:**
|
|
196
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
|
|
197
|
+
- [ ] Given a scan that drops the opencode-go head or the opencode tail the current pool has, When the refresh finishes, Then the previous pool is kept even at full count, because the structure is the contract.
|
|
198
|
+
- [ ] Given opencode's serving path reports a hard outage on the live status and the current pool is structurally intact, When the refresh runs, Then no scan is started, the pool is kept, and a recheck is scheduled in 5 minutes.
|
|
199
|
+
- [ ] Given the status reports degraded (latency, elevated errors) rather than down, When the refresh runs, Then the scan proceeds as normal, because models routinely answer while the page says degraded.
|
|
198
200
|
- [ ] 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
201
|
- [ ] 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
202
|
- [ ] Given a thin or missing pool, When a fuller scan completes, Then it is always written — the guard only ever blocks shrinking, never recovery.
|
|
@@ -654,6 +656,6 @@ The root-level suites under `tests/` are unit-style; the `tests/stories/` suites
|
|
|
654
656
|
the behavioural layer, written one epic per file with every test name carrying
|
|
655
657
|
its story id. Run everything with `npm test`.
|
|
656
658
|
|
|
657
|
-
40/40 stories covered by
|
|
659
|
+
40/40 stories covered by 377 test case(s); thinnest story has 1.
|
|
658
660
|
No story is untested. The scan covers the `tests/stories/` suites and the root-level
|
|
659
661
|
`tests/*-test.mjs` suites.
|
package/package.json
CHANGED
package/scripts/refresh-pool.mjs
CHANGED
|
@@ -24,6 +24,7 @@ import { homedir, userInfo } from "node:os"
|
|
|
24
24
|
import { dirname, join, resolve } from "node:path"
|
|
25
25
|
import { fileURLToPath } from "node:url"
|
|
26
26
|
import { fetchProviderStatus } from "./provider-status.mjs"
|
|
27
|
+
import { POOL_SIZE } from "./pool-selection.mjs"
|
|
27
28
|
|
|
28
29
|
const HOME = homedir()
|
|
29
30
|
export const DEFAULT_SCORES_PATH = join(HOME, ".config", "lacode", "model-scores.json")
|
|
@@ -209,23 +210,83 @@ export function poolSlotCount(text) {
|
|
|
209
210
|
return splitHeaderAndSlots(text).slots.filter(isSlotLine).length
|
|
210
211
|
}
|
|
211
212
|
|
|
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/")
|
|
217
|
+
}
|
|
218
|
+
|
|
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
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
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.
|
|
231
|
+
*/
|
|
232
|
+
export function shouldSkipScan({ providerStatus, currentPoolText }) {
|
|
233
|
+
if (!providerStatus?.ok || providerStatus.stale) {
|
|
234
|
+
return { skip: false, reason: "no trustworthy live signal; scanning" }
|
|
235
|
+
}
|
|
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" }
|
|
239
|
+
}
|
|
240
|
+
if (!poolHasHead(currentPoolText) || !poolHasTail(currentPoolText)) {
|
|
241
|
+
return { skip: false, reason: "pool structure already broken; scanning to repair it" }
|
|
242
|
+
}
|
|
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`,
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
212
249
|
/**
|
|
213
|
-
* Pure fail-closed guard. A refresh must never
|
|
214
|
-
* degenerate selection:
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
250
|
+
* Pure fail-closed guard. A refresh must never break the pool's structure or
|
|
251
|
+
* replace a working pool with a degenerate selection: the new pool must keep
|
|
252
|
+
* the opencode-go head and the opencode tail the current pool has, and hold at
|
|
253
|
+
* least half of POOL_SIZE. The baseline is the contract size, never the
|
|
254
|
+
* current file — a previously shrunk pool can always grow back, but no write
|
|
255
|
+
* may shrink or behead it. There is no previous pool on a first-ever run, so
|
|
256
|
+
* there is nothing to protect and the guard passes. `--force-write` is the
|
|
257
|
+
* human override for a genuinely changed catalogue.
|
|
218
258
|
*/
|
|
219
|
-
export function shouldKeepPool({ currentPoolText,
|
|
259
|
+
export function shouldKeepPool({ currentPoolText, newSlots }) {
|
|
220
260
|
const prev = poolSlotCount(currentPoolText ?? "")
|
|
221
261
|
if (prev <= 0) return { keep: false, prev, threshold: 0, reason: "no previous pool to protect" }
|
|
222
|
-
const
|
|
223
|
-
|
|
262
|
+
const slots = Array.isArray(newSlots) ? newSlots : []
|
|
263
|
+
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) {
|
|
277
|
+
return {
|
|
278
|
+
keep: true,
|
|
279
|
+
prev,
|
|
280
|
+
threshold,
|
|
281
|
+
reason: `new selection has no opencode tail slot while the previous pool has one; refusing to dock the tail`,
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
if (slots.length >= threshold) return { keep: false, prev, threshold, reason: "" }
|
|
224
285
|
return {
|
|
225
286
|
keep: true,
|
|
226
287
|
prev,
|
|
227
288
|
threshold,
|
|
228
|
-
reason: `new selection has ${
|
|
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`,
|
|
229
290
|
}
|
|
230
291
|
}
|
|
231
292
|
|
|
@@ -383,15 +444,19 @@ function usage() {
|
|
|
383
444
|
--max N models per provider passed to the scorer (default: 50)
|
|
384
445
|
--scores PATH scores file (default: ${DEFAULT_SCORES_PATH})
|
|
385
446
|
--pool PATH pool file to read/compare/write (default: ${DEFAULT_POOL_PATH})
|
|
447
|
+
--force-write write the new selection even when the fail-closed guard would
|
|
448
|
+
keep the previous pool (human override for a changed catalogue)
|
|
386
449
|
-h, --help this message
|
|
387
450
|
|
|
388
451
|
Degradation behaviour: before scanning, the refresh checks
|
|
389
|
-
https://status.opencode.ai/api/status.
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
the
|
|
393
|
-
|
|
394
|
-
|
|
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.`)
|
|
395
460
|
}
|
|
396
461
|
|
|
397
462
|
async function main() {
|
|
@@ -404,6 +469,7 @@ async function main() {
|
|
|
404
469
|
const fromScores = argv.includes("--from-scores")
|
|
405
470
|
const printOnly = argv.includes("--print") || argv.includes("--dry-run")
|
|
406
471
|
const noRetry = argv.includes("--no-retry")
|
|
472
|
+
const forceWrite = argv.includes("--force-write")
|
|
407
473
|
const value = (flag, fallback) => {
|
|
408
474
|
const hit = argv.find((a) => a === flag || a.startsWith(`${flag}=`))
|
|
409
475
|
if (!hit) return fallback
|
|
@@ -423,23 +489,21 @@ async function main() {
|
|
|
423
489
|
|
|
424
490
|
const currentPoolText = readTextFile(poolPath)
|
|
425
491
|
|
|
426
|
-
// Live degradation signal.
|
|
427
|
-
//
|
|
428
|
-
// pool
|
|
429
|
-
//
|
|
430
|
-
//
|
|
431
|
-
let providerStatus = { ok: false, providers: [], services: [] }
|
|
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
|
|
496
|
+
// protects the pool if the scan comes back degenerate.
|
|
497
|
+
let providerStatus = { ok: false, providers: [], services: [], down: [] }
|
|
432
498
|
if (!printOnly) {
|
|
433
499
|
try {
|
|
434
500
|
providerStatus = await fetchProviderStatus()
|
|
435
501
|
} catch (error) {
|
|
436
|
-
providerStatus = { ok: false, providers: [], services: [], reason: String(error?.message ?? error) }
|
|
502
|
+
providerStatus = { ok: false, providers: [], services: [], down: [], reason: String(error?.message ?? error) }
|
|
437
503
|
}
|
|
438
|
-
|
|
439
|
-
|
|
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
|
-
)
|
|
504
|
+
const skip = shouldSkipScan({ providerStatus, currentPoolText })
|
|
505
|
+
if (skip.skip) {
|
|
506
|
+
console.log(`${skip.reason}; keeping the current pool of ${poolSlotCount(currentPoolText)} slots`)
|
|
443
507
|
if (!noRetry) {
|
|
444
508
|
const scheduled = scheduleRetrySoon()
|
|
445
509
|
console.log(
|
|
@@ -457,6 +521,14 @@ async function main() {
|
|
|
457
521
|
runScorer(max)
|
|
458
522
|
} catch (error) {
|
|
459
523
|
console.error(`scorer failed; pool.txt not written: ${String(error?.message ?? error)}`)
|
|
524
|
+
if (!printOnly && !noRetry) {
|
|
525
|
+
const scheduled = scheduleRetrySoon()
|
|
526
|
+
console.log(
|
|
527
|
+
scheduled.scheduled
|
|
528
|
+
? `scheduled degraded recheck in ${scheduled.seconds}s`
|
|
529
|
+
: `degraded recheck not scheduled: ${scheduled.reason}`,
|
|
530
|
+
)
|
|
531
|
+
}
|
|
460
532
|
process.exitCode = 1
|
|
461
533
|
return
|
|
462
534
|
}
|
|
@@ -476,6 +548,14 @@ async function main() {
|
|
|
476
548
|
built = buildPool({ scores, currentPoolText, now: new Date() })
|
|
477
549
|
} catch (error) {
|
|
478
550
|
console.error(`refresh refused (fail closed): ${String(error?.message ?? error)}`)
|
|
551
|
+
if (!printOnly && !noRetry) {
|
|
552
|
+
const scheduled = scheduleRetrySoon()
|
|
553
|
+
console.log(
|
|
554
|
+
scheduled.scheduled
|
|
555
|
+
? `scheduled degraded recheck in ${scheduled.seconds}s`
|
|
556
|
+
: `degraded recheck not scheduled: ${scheduled.reason}`,
|
|
557
|
+
)
|
|
558
|
+
}
|
|
479
559
|
process.exitCode = 1
|
|
480
560
|
return
|
|
481
561
|
}
|
|
@@ -491,8 +571,9 @@ async function main() {
|
|
|
491
571
|
|
|
492
572
|
// Fail closed on a degenerate selection: keep the previous pool, say so
|
|
493
573
|
// loudly, and recheck in 5 minutes instead of waiting out a long reset.
|
|
494
|
-
|
|
495
|
-
|
|
574
|
+
// --force-write is the human override for a genuinely changed catalogue.
|
|
575
|
+
if (!printOnly && !forceWrite) {
|
|
576
|
+
const kept = shouldKeepPool({ currentPoolText, newSlots: built.slots })
|
|
496
577
|
if (kept.keep) {
|
|
497
578
|
console.error(`refresh refused (fail closed): ${kept.reason}`)
|
|
498
579
|
if (!noRetry) {
|
package/scripts/score-models.mjs
CHANGED
|
@@ -104,7 +104,7 @@ const value = (flag, fallback) => {
|
|
|
104
104
|
--min-context N exclude models whose context limit is below N (default: ${MIN_CONTEXT})
|
|
105
105
|
--min-score N do not rank models scoring below N (default: ${MIN_SCORE})
|
|
106
106
|
--free-only only probe catalogue cost 0/0 models on every provider
|
|
107
|
-
--timeout MS per-request deadline in ms (default:
|
|
107
|
+
--timeout MS per-request deadline in ms (default: 12000)
|
|
108
108
|
--delay MS pause between requests in ms (default: 300)
|
|
109
109
|
--budget-min N stop starting new probes after N minutes and write what was
|
|
110
110
|
measured; providers not scanned keep their previous rows
|
|
@@ -129,7 +129,7 @@ const providers = providerArg ? String(providerArg).split(",").map((s) => s.trim
|
|
|
129
129
|
const maxPerProvider = Math.max(1, Number(value("--max", "50")) || 50)
|
|
130
130
|
const minContext = Math.max(0, Number(value("--min-context", String(MIN_CONTEXT))) || MIN_CONTEXT)
|
|
131
131
|
const minScore = Math.max(0, Number(value("--min-score", String(MIN_SCORE))) || MIN_SCORE)
|
|
132
|
-
const timeoutMs = Math.max(1_000, Number(value("--timeout", "
|
|
132
|
+
const timeoutMs = Math.max(1_000, Number(value("--timeout", "12000")) || 12_000)
|
|
133
133
|
const delayMs = Math.max(0, Number(value("--delay", "300")) || 300)
|
|
134
134
|
const budgetMin = Math.max(1, Number(value("--budget-min", "30")) || 30)
|
|
135
135
|
const budgetMs = budgetMin * 60_000
|