@lawaty/lacode 2.3.0-beta.1 → 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 CHANGED
@@ -9,6 +9,19 @@ 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
+
12
25
  ## [2.3.0-beta.1] — provider degradation backoff
13
26
 
14
27
  ### Added
@@ -180,12 +180,26 @@ A daily scan was killed by a reboot mid-run, and because systemd records a trigg
180
180
  **Acceptance criteria:**
181
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`.
182
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.
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.
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.
184
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
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.
186
186
  - [ ] Given enough other providers to fill the pool, When selection runs, Then exactly one `opencode` slot is reserved last with weight 2.
187
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.
188
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
+
189
203
  ---
190
204
 
191
205
  ## Epic C — Hang detection and auto-recovery
@@ -606,6 +620,7 @@ table was filled in.
606
620
  | US-28 | B | `tests/stories/i-omniroute.test.mjs` |
607
621
  | US-31 | B | `tests/stories/b-provider-resilience.test.mjs`, `tests/stories/c-hang-recovery.test.mjs` |
608
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` |
609
624
  | US-8 | C | `tests/stories/c-hang-recovery.test.mjs`, `tests/pool-hang-test.mjs` |
610
625
  | US-9 | C | `tests/stories/c-hang-recovery.test.mjs`, `tests/pool-hang-test.mjs`, `tests/pool-shared-test.mjs` |
611
626
  | US-10 | C | `tests/stories/c-hang-recovery.test.mjs`, `tests/pool-hang-test.mjs`, `tests/pool-timer-test.mjs` |
@@ -639,6 +654,6 @@ The root-level suites under `tests/` are unit-style; the `tests/stories/` suites
639
654
  the behavioural layer, written one epic per file with every test name carrying
640
655
  its story id. Run everything with `npm test`.
641
656
 
642
- 39/39 stories covered by 372 test case(s); thinnest story has 1.
657
+ 40/40 stories covered by 375 test case(s); thinnest story has 1.
643
658
  No story is untested. The scan covers the `tests/stories/` suites and the root-level
644
659
  `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.1",
3
+ "version": "2.3.0-beta.2",
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",
@@ -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
- export function scheduleRetry(retry, options = {}) {
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 { scheduled: false, reason: "no reset time" }
195
- const now = options.now ?? Date.now
196
- const nowMs = now()
197
- const seconds = Math.ceil((at - nowMs) / 1000)
198
- if (!Number.isFinite(seconds) || seconds <= 0) return { scheduled: false, reason: "reset is not in the future" }
199
- if (seconds > 24 * 60 * 60) return { scheduled: false, reason: "reset is more than a day away" }
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, after: retry.after }
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 = splitHeaderAndSlots(currentPoolText).slots.filter(isSlotLine).length
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
- if (!printOnly && !noRetry) {
386
- const scheduled = scheduleRetry(scores.retry)
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) at ${scheduled.after}`)
389
- } else if (scores.retry) {
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
  }