@lawaty/lacode 2.3.0-beta.3 → 2.3.0-beta.5
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 +23 -0
- package/dist/index.js +93 -51
- package/dist/pool.js +3 -1
- package/docs/USER-STORIES.md +21 -2
- package/package.json +1 -1
- package/scripts/pool-selection.mjs +167 -6
- package/scripts/refresh-pool.mjs +204 -48
- package/scripts/score-models.mjs +75 -6
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,29 @@ commit as the changelog entry it describes.
|
|
|
9
9
|
|
|
10
10
|
## [Unreleased]
|
|
11
11
|
|
|
12
|
+
## [2.3.0-beta.5] — bench in place, cheap recovery, visible slots
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- Pool: selection retains current pool members unless the run proved them hard-dead (gone, credential, agreement, credit). Head and tail are never vacated by a failed scan; a healthy-but-capped model is still decided by the caps, and a catalogue removal on a clean scan vacates the slot.
|
|
17
|
+
- Pool: the refresh benches kept-but-transiently-failed members in place with a flat `scan-kept` cooling record (never a strike) and clears stale benches for members that answered. The runtime re-admits a benched model on the first answering probe.
|
|
18
|
+
- Pool: degraded retries run a canary over the current members first (`--canary`) and scan fully only when one stops answering. The scorer learned `--probe-models`, `--deprioritize-providers`, `--current-pool`, and `--out` to support it.
|
|
19
|
+
- Pool: the status page is now hint-only. Sick providers probe last; nothing is ever skipped or benched because of the page.
|
|
20
|
+
- Pool: `pool_status` shows each cooling slot's absolute expiry, the structural verdict (head/tail/count vs the 10-slot contract), the last refresh age, and the live status hint.
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
|
|
24
|
+
- Pool: per-request probe deadline is 12 seconds (was 45); `--timeout MS` still overrides it.
|
|
25
|
+
|
|
26
|
+
## [2.3.0-beta.4] — status never benches, structure is the contract
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- 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.
|
|
31
|
+
- 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.
|
|
32
|
+
- Pool: per-request probe deadline is 12 seconds (was 45), so a flapping provider stretches a scan far less; `--timeout MS` still overrides it.
|
|
33
|
+
- 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.
|
|
34
|
+
|
|
12
35
|
## [2.3.0-beta.3] — persist autonomy modes in global config
|
|
13
36
|
|
|
14
37
|
### Changed
|
package/dist/index.js
CHANGED
|
@@ -39,6 +39,7 @@ var UNAVAILABLE_COOLDOWN_MS = 60 * 1e3;
|
|
|
39
39
|
var UNAVAILABLE_REASON = "endpoint-unavailable";
|
|
40
40
|
var PROVIDER_DEGRADED_REASON = "provider-degraded";
|
|
41
41
|
var PROVIDER_DEGRADED_COOLDOWN_MS = 30 * 60 * 1e3;
|
|
42
|
+
var SCAN_KEPT_REASON = "scan-kept";
|
|
42
43
|
var PROVIDER_DEGRADED_WINDOW_MS = 10 * 60 * 1e3;
|
|
43
44
|
var PROVIDER_DEGRADED_MIN_MODELS = 2;
|
|
44
45
|
var TRANSIENT_DEGRADATION_REASONS = /^(?:no answer|no-answer|hang|timeout|timed out|endpoint-unavailable|provider-degraded|empty response|network|server)$/i;
|
|
@@ -695,7 +696,7 @@ function readLimits(s) {
|
|
|
695
696
|
try {
|
|
696
697
|
const strike = JSON.parse(readFileSync2(path4, "utf8"));
|
|
697
698
|
if (typeof strike.until !== "number" || typeof strike.model !== "string") continue;
|
|
698
|
-
const countsAsStrike = strike.reason !== UNAVAILABLE_REASON;
|
|
699
|
+
const countsAsStrike = strike.reason !== UNAVAILABLE_REASON && strike.reason !== SCAN_KEPT_REASON;
|
|
699
700
|
if (strike.until > now) {
|
|
700
701
|
cooling.set(strike.model, strike);
|
|
701
702
|
if (countsAsStrike) memory.set(strike.model, strike);
|
|
@@ -2527,7 +2528,9 @@ async function maybeSelfUpdate(opts) {
|
|
|
2527
2528
|
}
|
|
2528
2529
|
|
|
2529
2530
|
// src/plugins/agent-pool.ts
|
|
2530
|
-
import { unwatchFile, watchFile } from "node:fs";
|
|
2531
|
+
import { readFileSync as readFileSync5, unwatchFile, watchFile } from "node:fs";
|
|
2532
|
+
import { homedir as homedir4 } from "node:os";
|
|
2533
|
+
import { join as join6 } from "node:path";
|
|
2531
2534
|
import { tool } from "@opencode-ai/plugin";
|
|
2532
2535
|
|
|
2533
2536
|
// src/lib/probe.ts
|
|
@@ -3179,6 +3182,43 @@ function estimateContextTokens(prompt) {
|
|
|
3179
3182
|
if (typeof prompt !== "string" || prompt.length === 0) return void 0;
|
|
3180
3183
|
return Math.ceil(prompt.length / 4) + CONTEXT_HEADROOM_TOKENS;
|
|
3181
3184
|
}
|
|
3185
|
+
var poolHealthLines = (slots, now) => {
|
|
3186
|
+
const lines = [];
|
|
3187
|
+
const heads = slots.filter((slot) => slot.model.startsWith("opencode-go/"));
|
|
3188
|
+
const headOk = heads.length > 0 && slots.slice(0, heads.length).every((slot) => slot.model.startsWith("opencode-go/"));
|
|
3189
|
+
const tail = slots[slots.length - 1];
|
|
3190
|
+
const tailOk = !!tail && tail.model.startsWith("opencode/") && tail.bucket === "opencode";
|
|
3191
|
+
const verdict = headOk && tailOk ? "intact" : "BROKEN";
|
|
3192
|
+
lines.push(
|
|
3193
|
+
`pool structure: ${verdict} (${slots.length}/10 slots, head=${headOk ? `${heads.length} opencode-go` : "missing"}, tail=${tailOk ? tail.model : "missing"})`
|
|
3194
|
+
);
|
|
3195
|
+
try {
|
|
3196
|
+
const text = readFileSync5(join6(homedir4(), ".config", "lacode", "pool.txt"), "utf8");
|
|
3197
|
+
const stamp = /^#\s*Refreshed\s+(\S+)/m.exec(text)?.[1];
|
|
3198
|
+
if (stamp) {
|
|
3199
|
+
const ageMs = now - Date.parse(stamp);
|
|
3200
|
+
if (Number.isFinite(ageMs) && ageMs >= 0) {
|
|
3201
|
+
const mins = Math.floor(ageMs / 6e4);
|
|
3202
|
+
lines.push(`last refresh: ${mins < 60 ? `${mins}m ago` : `${Math.floor(mins / 60)}h${mins % 60}m ago`} (${stamp})`);
|
|
3203
|
+
}
|
|
3204
|
+
}
|
|
3205
|
+
} catch {
|
|
3206
|
+
}
|
|
3207
|
+
try {
|
|
3208
|
+
const raw = readFileSync5(join6(homedir4(), ".config", "lacode", "provider-status.json"), "utf8");
|
|
3209
|
+
const status = JSON.parse(raw);
|
|
3210
|
+
if (status && typeof status === "object") {
|
|
3211
|
+
const ageS = typeof status.checkedAt === "number" ? Math.max(0, Math.round((now - status.checkedAt) / 1e3)) : -1;
|
|
3212
|
+
const bad = (status.services ?? []).filter((entry) => String(entry?.status ?? "") !== "operational");
|
|
3213
|
+
const detail = bad.length > 0 ? `: ${bad.map((entry) => `${entry.service}=${entry.status}`).join(", ")}` : "";
|
|
3214
|
+
lines.push(
|
|
3215
|
+
`status page: ${status.ok === false ? "unreachable" : status.overall ?? "unknown"}${detail}${ageS >= 0 ? ` (checked ${ageS}s ago)` : ""}`
|
|
3216
|
+
);
|
|
3217
|
+
}
|
|
3218
|
+
} catch {
|
|
3219
|
+
}
|
|
3220
|
+
return lines;
|
|
3221
|
+
};
|
|
3182
3222
|
var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
|
|
3183
3223
|
const opts = options ?? {};
|
|
3184
3224
|
const notify = opts.notify ?? ((title, message) => void notifyDesktop(title, message));
|
|
@@ -4278,8 +4318,9 @@ ${detail}` : detail;
|
|
|
4278
4318
|
const users = entry && entry.bases.size > 0 ? [...entry.bases].map(([b, n]) => `${b}x${n}`).join(" ") : "idle";
|
|
4279
4319
|
const strike = snap.cooling.get(slot.model);
|
|
4280
4320
|
const status = s.catalog.get(slot.model);
|
|
4321
|
+
const untilClock = strike ? new Date(strike.until).toISOString().slice(11, 19) + "Z" : "";
|
|
4281
4322
|
const notes = [
|
|
4282
|
-
strike ? `UNAVAILABLE (COOLING ${Math.ceil((strike.until - now) / 1e3)}s, ${strike.reason}, strike ${strike.strikes})` : "",
|
|
4323
|
+
strike ? `UNAVAILABLE (COOLING ${Math.ceil((strike.until - now) / 1e3)}s left, until ${untilClock}, ${strike.reason}, strike ${strike.strikes})` : "",
|
|
4283
4324
|
!providerReady(s, slot.model) ? `UNAVAILABLE (provider-unconfigured: ${providerOf(slot.model)}; run \`opencode auth login ${providerOf(slot.model)}\`)` : "",
|
|
4284
4325
|
status && status !== "active" && status !== "unknown" ? `catalog:${status}` : ""
|
|
4285
4326
|
].filter(Boolean);
|
|
@@ -4313,6 +4354,7 @@ one ordered pass in slot order: each slot fills to its weight, then overflow goe
|
|
|
4313
4354
|
...sharedBuckets.length ? [`buckets: ${sharedBuckets.map(([name, entry]) => `${name} ${entry.seats} seat(s)${entry.cooling ? " COOLING" : ""}`).join("; ")}`] : [],
|
|
4314
4355
|
...degradedProviders.length ? [`provider degraded: ${degradedProviders.join(", ")} (flat window; a probe re-admits a model when it answers)`] : [],
|
|
4315
4356
|
...slots.map(line),
|
|
4357
|
+
...poolHealthLines(slots, now),
|
|
4316
4358
|
"",
|
|
4317
4359
|
"by process:",
|
|
4318
4360
|
` ${s.id} (this process): ${snap.own.size} claim(s)`,
|
|
@@ -4380,11 +4422,11 @@ one ordered pass in slot order: each slot fills to its weight, then overflow goe
|
|
|
4380
4422
|
|
|
4381
4423
|
// src/plugins/context-autoupdate.ts
|
|
4382
4424
|
import { existsSync as existsSync4, mkdirSync as mkdirSync6, readdirSync as readdirSync3, statSync as statSync6, unlinkSync as unlinkSync5, writeFileSync as writeFileSync7 } from "node:fs";
|
|
4383
|
-
import { homedir as
|
|
4425
|
+
import { homedir as homedir6 } from "node:os";
|
|
4384
4426
|
import * as path3 from "node:path";
|
|
4385
4427
|
|
|
4386
4428
|
// src/lib/map-schema.ts
|
|
4387
|
-
import { existsSync as existsSync3, readFileSync as
|
|
4429
|
+
import { existsSync as existsSync3, readFileSync as readFileSync6, statSync as statSync5, writeFileSync as writeFileSync5 } from "node:fs";
|
|
4388
4430
|
import * as path from "node:path";
|
|
4389
4431
|
var PROVISIONAL_DAYS = 30;
|
|
4390
4432
|
var MAP_SPECS = [
|
|
@@ -4537,7 +4579,7 @@ var resolvePointer = (root, pointer, section) => {
|
|
|
4537
4579
|
if (!stat.isFile()) return { exists: false };
|
|
4538
4580
|
let text;
|
|
4539
4581
|
try {
|
|
4540
|
-
if (stat.size <= 512 * 1024) text =
|
|
4582
|
+
if (stat.size <= 512 * 1024) text = readFileSync6(absolute, "utf8");
|
|
4541
4583
|
} catch {
|
|
4542
4584
|
text = void 0;
|
|
4543
4585
|
}
|
|
@@ -4786,7 +4828,7 @@ var maintainMap = (root, mapDir, now = Date.now()) => {
|
|
|
4786
4828
|
if (!existsSync3(absolute)) continue;
|
|
4787
4829
|
let before;
|
|
4788
4830
|
try {
|
|
4789
|
-
before =
|
|
4831
|
+
before = readFileSync6(absolute, "utf8");
|
|
4790
4832
|
} catch {
|
|
4791
4833
|
continue;
|
|
4792
4834
|
}
|
|
@@ -4814,7 +4856,7 @@ var auditMap = (root, mapDir, now = Date.now()) => {
|
|
|
4814
4856
|
}
|
|
4815
4857
|
let text;
|
|
4816
4858
|
try {
|
|
4817
|
-
text =
|
|
4859
|
+
text = readFileSync6(absolute, "utf8");
|
|
4818
4860
|
} catch {
|
|
4819
4861
|
violations.push({ file: spec.file, kind: "missing-file", detail: "unreadable" });
|
|
4820
4862
|
continue;
|
|
@@ -4893,7 +4935,7 @@ var parseJsonc = (raw) => {
|
|
|
4893
4935
|
};
|
|
4894
4936
|
|
|
4895
4937
|
// src/lib/writer-rule.ts
|
|
4896
|
-
import { readFileSync as
|
|
4938
|
+
import { readFileSync as readFileSync7 } from "node:fs";
|
|
4897
4939
|
import * as path2 from "node:path";
|
|
4898
4940
|
var CONTEXT_DIR = path2.join(".opencode", "context");
|
|
4899
4941
|
var WRITER = "context";
|
|
@@ -4933,7 +4975,7 @@ var agentsForm = (mapDir, projectRoot) => {
|
|
|
4933
4975
|
var verifyWriterRule = (configPath, mapDir, projectRoot) => {
|
|
4934
4976
|
let raw;
|
|
4935
4977
|
try {
|
|
4936
|
-
raw =
|
|
4978
|
+
raw = readFileSync7(configPath, "utf8");
|
|
4937
4979
|
} catch {
|
|
4938
4980
|
return void 0;
|
|
4939
4981
|
}
|
|
@@ -4978,25 +5020,25 @@ var verifyWriterRule = (configPath, mapDir, projectRoot) => {
|
|
|
4978
5020
|
};
|
|
4979
5021
|
|
|
4980
5022
|
// src/lib/timeline.ts
|
|
4981
|
-
import { appendFileSync as appendFileSync2, mkdirSync as mkdirSync5, readdirSync as readdirSync2, readFileSync as
|
|
4982
|
-
import { homedir as
|
|
4983
|
-
import { dirname as dirname3, join as
|
|
5023
|
+
import { appendFileSync as appendFileSync2, mkdirSync as mkdirSync5, readdirSync as readdirSync2, readFileSync as readFileSync8, renameSync as renameSync4, rmSync as rmSync2, writeFileSync as writeFileSync6 } from "node:fs";
|
|
5024
|
+
import { homedir as homedir5 } from "node:os";
|
|
5025
|
+
import { dirname as dirname3, join as join9, resolve as resolve3 } from "node:path";
|
|
4984
5026
|
var TIMELINE_STATE_VERSION = 1;
|
|
4985
5027
|
function timelineDir() {
|
|
4986
5028
|
const env = process.env.LACODE_TIMELINE_DIR;
|
|
4987
5029
|
if (env) return resolve3(env);
|
|
4988
|
-
return
|
|
5030
|
+
return join9(homedir5(), ".local/share/opencode/lacode/timeline");
|
|
4989
5031
|
}
|
|
4990
5032
|
function assertValidSessionID(sessionID) {
|
|
4991
5033
|
if (!/^[A-Za-z0-9_.-]+$/.test(sessionID)) throw new Error("invalid sessionID");
|
|
4992
5034
|
}
|
|
4993
5035
|
function timelineFile(sessionID) {
|
|
4994
5036
|
assertValidSessionID(sessionID);
|
|
4995
|
-
return
|
|
5037
|
+
return join9(timelineDir(), sessionID + ".jsonl");
|
|
4996
5038
|
}
|
|
4997
5039
|
function timelineStateFile(sessionID) {
|
|
4998
5040
|
assertValidSessionID(sessionID);
|
|
4999
|
-
return
|
|
5041
|
+
return join9(timelineDir(), sessionID + ".state.json");
|
|
5000
5042
|
}
|
|
5001
5043
|
function normalizeThrough(through) {
|
|
5002
5044
|
if (!through || typeof through !== "object") return void 0;
|
|
@@ -5043,7 +5085,7 @@ function appendEntriesAt(file, texts, now, through) {
|
|
|
5043
5085
|
function readEntriesAt(file, limit) {
|
|
5044
5086
|
let raw = "";
|
|
5045
5087
|
try {
|
|
5046
|
-
raw =
|
|
5088
|
+
raw = readFileSync8(file, "utf8");
|
|
5047
5089
|
} catch {
|
|
5048
5090
|
return [];
|
|
5049
5091
|
}
|
|
@@ -5096,7 +5138,7 @@ function normalizePersistedState(raw) {
|
|
|
5096
5138
|
}
|
|
5097
5139
|
function readTimelineState(sessionID) {
|
|
5098
5140
|
try {
|
|
5099
|
-
const raw =
|
|
5141
|
+
const raw = readFileSync8(timelineStateFile(sessionID), "utf8");
|
|
5100
5142
|
return normalizePersistedState(JSON.parse(raw));
|
|
5101
5143
|
} catch {
|
|
5102
5144
|
return void 0;
|
|
@@ -5522,7 +5564,7 @@ var changedSince = (before, dir) => {
|
|
|
5522
5564
|
return changed;
|
|
5523
5565
|
};
|
|
5524
5566
|
var RETAIN_TRANSCRIPTS = 20;
|
|
5525
|
-
var transcriptDir = () => process.env.LACODE_TRANSCRIPT_DIR ?? path3.join(
|
|
5567
|
+
var transcriptDir = () => process.env.LACODE_TRANSCRIPT_DIR ?? path3.join(homedir6(), ".local", "share", "opencode", "lacode", "cartographer");
|
|
5526
5568
|
var retireSession = async (client, directory, sessionID, mode, log) => {
|
|
5527
5569
|
try {
|
|
5528
5570
|
const transcript = await client.session.messages({ path: { id: sessionID }, query: { directory } }).then((r) => r.data).catch(() => void 0);
|
|
@@ -5778,15 +5820,15 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
|
|
|
5778
5820
|
});
|
|
5779
5821
|
|
|
5780
5822
|
// src/lib/feedback.ts
|
|
5781
|
-
import { mkdirSync as mkdirSync7, readFileSync as
|
|
5782
|
-
import { homedir as
|
|
5783
|
-
import { dirname as dirname5, join as
|
|
5823
|
+
import { mkdirSync as mkdirSync7, readFileSync as readFileSync9, renameSync as renameSync5, unlinkSync as unlinkSync6, writeFileSync as writeFileSync8 } from "node:fs";
|
|
5824
|
+
import { homedir as homedir7 } from "node:os";
|
|
5825
|
+
import { dirname as dirname5, join as join11 } from "node:path";
|
|
5784
5826
|
var FEEDBACK_HINT_ENV = "LACODE_NO_FEEDBACK_HINT";
|
|
5785
5827
|
var FEEDBACK_HINT = "Enjoying lacode? Bugs, ideas, or a quick note on how you use it: github.com/lawaty/lacode/issues (a \u2B50 on the repo helps others find it)";
|
|
5786
|
-
var feedbackStatePath = () =>
|
|
5828
|
+
var feedbackStatePath = () => join11(homedir7(), ".local", "share", "opencode", "lacode", "feedback.json");
|
|
5787
5829
|
function readFeedbackState(path4 = feedbackStatePath()) {
|
|
5788
5830
|
try {
|
|
5789
|
-
const parsed = JSON.parse(
|
|
5831
|
+
const parsed = JSON.parse(readFileSync9(path4, "utf8"));
|
|
5790
5832
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return void 0;
|
|
5791
5833
|
return parsed;
|
|
5792
5834
|
} catch (error) {
|
|
@@ -5860,9 +5902,9 @@ async function feedbackHooks(input, options, deps = {}) {
|
|
|
5860
5902
|
|
|
5861
5903
|
// src/plugins/image-paste.ts
|
|
5862
5904
|
import { createHash, randomUUID } from "node:crypto";
|
|
5863
|
-
import { globSync, mkdirSync as mkdirSync8, readFileSync as
|
|
5864
|
-
import { homedir as
|
|
5865
|
-
import { basename as basename3, join as
|
|
5905
|
+
import { globSync, mkdirSync as mkdirSync8, readFileSync as readFileSync10, renameSync as renameSync6, statSync as statSync7, writeFileSync as writeFileSync9 } from "node:fs";
|
|
5906
|
+
import { homedir as homedir8 } from "node:os";
|
|
5907
|
+
import { basename as basename3, join as join12 } from "node:path";
|
|
5866
5908
|
|
|
5867
5909
|
// src/lib/clipboard.ts
|
|
5868
5910
|
import { spawnSync } from "node:child_process";
|
|
@@ -6012,8 +6054,8 @@ function splitArguments(input) {
|
|
|
6012
6054
|
return out;
|
|
6013
6055
|
}
|
|
6014
6056
|
function expandTilde(p) {
|
|
6015
|
-
if (p === "~") return
|
|
6016
|
-
if (p.startsWith("~/")) return
|
|
6057
|
+
if (p === "~") return homedir8();
|
|
6058
|
+
if (p.startsWith("~/")) return join12(homedir8(), p.slice(2));
|
|
6017
6059
|
return p;
|
|
6018
6060
|
}
|
|
6019
6061
|
function expandGlob(p) {
|
|
@@ -6029,7 +6071,7 @@ function storeImage(bytes, dir, sessionID, label) {
|
|
|
6029
6071
|
if (kind === null) return null;
|
|
6030
6072
|
const hash = createHash("sha256").update(bytes).digest("hex");
|
|
6031
6073
|
mkdirSync8(dir, { recursive: true });
|
|
6032
|
-
const target =
|
|
6074
|
+
const target = join12(dir, `${hash}.${kind.ext}`);
|
|
6033
6075
|
const tmp = `${target}.tmp`;
|
|
6034
6076
|
writeFileSync9(tmp, bytes, { mode: 384 });
|
|
6035
6077
|
renameSync6(tmp, target);
|
|
@@ -6060,7 +6102,7 @@ async function imagePasteHooks(input, options, deps) {
|
|
|
6060
6102
|
if (opts.enabled === false) return {};
|
|
6061
6103
|
const maxBytes = resolveMaxBytes(opts);
|
|
6062
6104
|
const maxImages = resolveMaxImages(opts);
|
|
6063
|
-
const dir = opts.dir ?? process.env.LACODE_IMAGE_DIR ??
|
|
6105
|
+
const dir = opts.dir ?? process.env.LACODE_IMAGE_DIR ?? join12(homedir8(), ".local/share/opencode/lacode/images");
|
|
6064
6106
|
const readClipboard = (deps ?? {}).readClipboard ?? readClipboardImage;
|
|
6065
6107
|
return {
|
|
6066
6108
|
"command.execute.before": async (cmd, output) => {
|
|
@@ -6117,7 +6159,7 @@ async function imagePasteHooks(input, options, deps) {
|
|
|
6117
6159
|
}
|
|
6118
6160
|
let bytes;
|
|
6119
6161
|
try {
|
|
6120
|
-
bytes =
|
|
6162
|
+
bytes = readFileSync10(filePath);
|
|
6121
6163
|
} catch {
|
|
6122
6164
|
skipped.push(name);
|
|
6123
6165
|
continue;
|
|
@@ -6168,14 +6210,14 @@ async function imagePasteHooks(input, options, deps) {
|
|
|
6168
6210
|
}
|
|
6169
6211
|
|
|
6170
6212
|
// src/lib/opencode-config.ts
|
|
6171
|
-
import { existsSync as existsSync5, mkdirSync as mkdirSync9, readFileSync as
|
|
6172
|
-
import { homedir as
|
|
6173
|
-
import { dirname as dirname6, join as
|
|
6213
|
+
import { existsSync as existsSync5, mkdirSync as mkdirSync9, readFileSync as readFileSync11, renameSync as renameSync7, writeFileSync as writeFileSync10 } from "node:fs";
|
|
6214
|
+
import { homedir as homedir9 } from "node:os";
|
|
6215
|
+
import { dirname as dirname6, join as join13 } from "node:path";
|
|
6174
6216
|
import { applyEdits, modify, parse, printParseErrorCode } from "jsonc-parser";
|
|
6175
6217
|
function globalOpencodeConfigPath() {
|
|
6176
|
-
const base = process.env.XDG_CONFIG_HOME ??
|
|
6177
|
-
const jsonc =
|
|
6178
|
-
return existsSync5(jsonc) ? jsonc :
|
|
6218
|
+
const base = process.env.XDG_CONFIG_HOME ?? join13(homedir9(), ".config");
|
|
6219
|
+
const jsonc = join13(base, "opencode", "opencode.jsonc");
|
|
6220
|
+
return existsSync5(jsonc) ? jsonc : join13(base, "opencode", "opencode.json");
|
|
6179
6221
|
}
|
|
6180
6222
|
var OpenCodeConfigError = class extends Error {
|
|
6181
6223
|
constructor(message) {
|
|
@@ -6188,7 +6230,7 @@ function isPlainObject(value) {
|
|
|
6188
6230
|
}
|
|
6189
6231
|
function readOpencodeConfig(path4 = globalOpencodeConfigPath()) {
|
|
6190
6232
|
if (!existsSync5(path4)) return {};
|
|
6191
|
-
const text =
|
|
6233
|
+
const text = readFileSync11(path4, "utf8");
|
|
6192
6234
|
const errors = [];
|
|
6193
6235
|
const parsed = parse(text, errors, { allowTrailingComma: true, disallowComments: false });
|
|
6194
6236
|
if (errors.length > 0) {
|
|
@@ -7424,7 +7466,7 @@ async function selfUpdateHooks(input, options) {
|
|
|
7424
7466
|
|
|
7425
7467
|
// src/plugins/timeline.ts
|
|
7426
7468
|
import { existsSync as existsSync6, writeFileSync as writeFileSync11, renameSync as renameSync8, rmSync as rmSync3 } from "node:fs";
|
|
7427
|
-
import { join as
|
|
7469
|
+
import { join as join14 } from "node:path";
|
|
7428
7470
|
import { tool as tool2 } from "@opencode-ai/plugin";
|
|
7429
7471
|
var DEFAULT_INTERVAL_MS = 3e5;
|
|
7430
7472
|
var MIN_INTERVAL_MS = 1e4;
|
|
@@ -7500,7 +7542,7 @@ function errorText(value) {
|
|
|
7500
7542
|
}
|
|
7501
7543
|
var MAX_TASK_CHILDREN_EXPANDED = 40;
|
|
7502
7544
|
async function expandTaskSessions(client, messages, directory, owned, onError, cursor) {
|
|
7503
|
-
const projectOwnsTooling = existsSync6(
|
|
7545
|
+
const projectOwnsTooling = existsSync6(join14(directory, "src/plugins/timeline.ts"));
|
|
7504
7546
|
let attempts = 0;
|
|
7505
7547
|
const expandedMessages = [];
|
|
7506
7548
|
let startIndex = 0;
|
|
@@ -8066,9 +8108,9 @@ function timelineHooks(input, options) {
|
|
|
8066
8108
|
|
|
8067
8109
|
// src/lib/timer-migration.ts
|
|
8068
8110
|
import { execFile } from "node:child_process";
|
|
8069
|
-
import { existsSync as existsSync7, mkdirSync as mkdirSync10, readFileSync as
|
|
8070
|
-
import { homedir as
|
|
8071
|
-
import { basename as basename4, dirname as dirname7, join as
|
|
8111
|
+
import { existsSync as existsSync7, mkdirSync as mkdirSync10, readFileSync as readFileSync12, renameSync as renameSync9, writeFileSync as writeFileSync12 } from "node:fs";
|
|
8112
|
+
import { homedir as homedir10 } from "node:os";
|
|
8113
|
+
import { basename as basename4, dirname as dirname7, join as join15 } from "node:path";
|
|
8072
8114
|
var TIMER_MIGRATION_ENV = "LACODE_NO_TIMER_MIGRATION";
|
|
8073
8115
|
var TIMER_MIGRATION_STATE_VERSION = 1;
|
|
8074
8116
|
var defaultRun2 = (file, args, options) => new Promise((resolve5, reject) => {
|
|
@@ -8082,15 +8124,15 @@ function resolveNodeBinary(execPath = process.execPath) {
|
|
|
8082
8124
|
if (existsSync7("/usr/bin/node")) return "/usr/bin/node";
|
|
8083
8125
|
return "node";
|
|
8084
8126
|
}
|
|
8085
|
-
function timerMigrationStatePath(home =
|
|
8086
|
-
return
|
|
8127
|
+
function timerMigrationStatePath(home = homedir10()) {
|
|
8128
|
+
return join15(home, ".local", "share", "opencode", "lacode", "migrations.json");
|
|
8087
8129
|
}
|
|
8088
|
-
function timerMigrationUnitPath(home =
|
|
8089
|
-
return
|
|
8130
|
+
function timerMigrationUnitPath(home = homedir10()) {
|
|
8131
|
+
return join15(home, ".config", "systemd", "user", "lacode-refresh-pool.timer");
|
|
8090
8132
|
}
|
|
8091
8133
|
function readState2(path4) {
|
|
8092
8134
|
try {
|
|
8093
|
-
return JSON.parse(
|
|
8135
|
+
return JSON.parse(readFileSync12(path4, "utf8"));
|
|
8094
8136
|
} catch {
|
|
8095
8137
|
return {};
|
|
8096
8138
|
}
|
|
@@ -8109,7 +8151,7 @@ async function runTimerMigration(options = {}) {
|
|
|
8109
8151
|
const packageRoot = options.packageRoot ?? PACKAGE_ROOT;
|
|
8110
8152
|
const version = options.version ?? readInstalledVersion(packageRoot);
|
|
8111
8153
|
if (!version) return { action: "skipped", reason: "no-version" };
|
|
8112
|
-
const home = options.home ??
|
|
8154
|
+
const home = options.home ?? homedir10();
|
|
8113
8155
|
const statePath = options.statePath ?? timerMigrationStatePath(home);
|
|
8114
8156
|
const state2 = readState2(statePath);
|
|
8115
8157
|
if (state2.timersVersion === version && existsSync7(timerMigrationUnitPath(home))) {
|
|
@@ -8121,7 +8163,7 @@ async function runTimerMigration(options = {}) {
|
|
|
8121
8163
|
} catch {
|
|
8122
8164
|
return { action: "skipped", reason: "no-systemd" };
|
|
8123
8165
|
}
|
|
8124
|
-
const script =
|
|
8166
|
+
const script = join15(packageRoot, "scripts", "refresh-pool.mjs");
|
|
8125
8167
|
if (!existsSync7(script)) return { action: "failed", version, error: `timer script not found: ${script}` };
|
|
8126
8168
|
try {
|
|
8127
8169
|
await run(resolveNodeBinary(), [script, "--install-timer", "--stable"], { stdio: "ignore" });
|
package/dist/pool.js
CHANGED
|
@@ -39,6 +39,7 @@ var UNAVAILABLE_COOLDOWN_MS = 60 * 1e3;
|
|
|
39
39
|
var UNAVAILABLE_REASON = "endpoint-unavailable";
|
|
40
40
|
var PROVIDER_DEGRADED_REASON = "provider-degraded";
|
|
41
41
|
var PROVIDER_DEGRADED_COOLDOWN_MS = 30 * 60 * 1e3;
|
|
42
|
+
var SCAN_KEPT_REASON = "scan-kept";
|
|
42
43
|
var PROVIDER_DEGRADED_WINDOW_MS = 10 * 60 * 1e3;
|
|
43
44
|
var PROVIDER_DEGRADED_MIN_MODELS = 2;
|
|
44
45
|
var TRANSIENT_DEGRADATION_REASONS = /^(?:no answer|no-answer|hang|timeout|timed out|endpoint-unavailable|provider-degraded|empty response|network|server)$/i;
|
|
@@ -695,7 +696,7 @@ function readLimits(s) {
|
|
|
695
696
|
try {
|
|
696
697
|
const strike = JSON.parse(readFileSync2(path, "utf8"));
|
|
697
698
|
if (typeof strike.until !== "number" || typeof strike.model !== "string") continue;
|
|
698
|
-
const countsAsStrike = strike.reason !== UNAVAILABLE_REASON;
|
|
699
|
+
const countsAsStrike = strike.reason !== UNAVAILABLE_REASON && strike.reason !== SCAN_KEPT_REASON;
|
|
699
700
|
if (strike.until > now) {
|
|
700
701
|
cooling.set(strike.model, strike);
|
|
701
702
|
if (countsAsStrike) memory.set(strike.model, strike);
|
|
@@ -1098,6 +1099,7 @@ export {
|
|
|
1098
1099
|
PROVIDER_LIST_TTL_MS,
|
|
1099
1100
|
QUOTA_COOLDOWN_BASE_MS,
|
|
1100
1101
|
QUOTA_COOLDOWN_MAX_MS,
|
|
1102
|
+
SCAN_KEPT_REASON,
|
|
1101
1103
|
SLOTS_WARN_AT,
|
|
1102
1104
|
STATED_COOLDOWN_MAX_MS,
|
|
1103
1105
|
STATE_SWEEP_INTERVAL_MS,
|
package/docs/USER-STORIES.md
CHANGED
|
@@ -194,12 +194,30 @@ 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.
|
|
201
203
|
- [ ] 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
204
|
|
|
205
|
+
### US-41: The pool keeps its structure through any outage and shows its state
|
|
206
|
+
**As a** user relying on the pool, **I want** members to be benched in place instead of deleted, recovery to be cheap, and the slot states visible, **so that** no outage can shrink the pool, no recovery takes longer than minutes, and I can see what each slot is doing.
|
|
207
|
+
|
|
208
|
+
Selection retains current members unless the run proved them hard-dead (gone, credential, agreement, credit); a healthy-but-capped model is still decided by the caps. The refresh writes a flat `scan-kept` cooling record for every kept-but-transiently-failed member — the runtime already honours it and re-admits the model on the first answering probe — and clears stale benches for members that answered. The status page is a hint only: sick providers probe last, nothing is ever skipped or benched because of it. Degraded retries run a canary over the current members first and scan fully only when one stops answering. `pool_status` shows each cooling slot's absolute expiry, the structural verdict, the last refresh age, and the status hint.
|
|
209
|
+
|
|
210
|
+
**Story ID(s) for tests:** US-41
|
|
211
|
+
**Acceptance criteria:**
|
|
212
|
+
- [ ] Given a current member whose probe failed transiently, When selection runs, Then the member is retained in place (marked retained) with its pool weight, and head/tail positions are never vacated.
|
|
213
|
+
- [ ] Given a current member that is healthy but capped out, When selection runs, Then it is not retained — the caps decide.
|
|
214
|
+
- [ ] Given a model absent from a cleanly scanned catalogue, When selection runs, Then it is dropped as hard-dead, not retained.
|
|
215
|
+
- [ ] Given a retained member, When the refresh finishes, Then a flat `scan-kept` cooling record benches it without entering the strike memory, and a member that answered has any stale bench cleared.
|
|
216
|
+
- [ ] Given a scan-kept bench, When any probe watches the model answer, Then it is re-admitted without waiting out the window.
|
|
217
|
+
- [ ] Given a degraded page, When the refresh runs, Then sick providers only probe last; nothing is skipped or benched because of the page.
|
|
218
|
+
- [ ] Given a degraded retry, When it runs, Then it probes only the current members first and scans fully only when one stops answering.
|
|
219
|
+
- [ ] Given cooling slots, When `pool_status` renders, Then each shows its absolute expiry, plus the structural verdict, last refresh age, and status hint.
|
|
220
|
+
|
|
203
221
|
---
|
|
204
222
|
|
|
205
223
|
## Epic C — Hang detection and auto-recovery
|
|
@@ -621,6 +639,7 @@ table was filled in.
|
|
|
621
639
|
| US-31 | B | `tests/stories/b-provider-resilience.test.mjs`, `tests/stories/c-hang-recovery.test.mjs` |
|
|
622
640
|
| US-39 | B | `tests/stories/l-pool-refresh.test.mjs` |
|
|
623
641
|
| US-40 | B | `tests/stories/l-pool-refresh.test.mjs`, `tests/refresh-pool-test.mjs`, `tests/provider-status-test.mjs` |
|
|
642
|
+
| US-41 | B | `tests/stories/l-pool-refresh.test.mjs`, `tests/pool-selection-test.mjs`, `tests/refresh-pool-test.mjs`, `tests/pool-shared-test.mjs`, `tests/provider-status-test.mjs` |
|
|
624
643
|
| US-8 | C | `tests/stories/c-hang-recovery.test.mjs`, `tests/pool-hang-test.mjs` |
|
|
625
644
|
| US-9 | C | `tests/stories/c-hang-recovery.test.mjs`, `tests/pool-hang-test.mjs`, `tests/pool-shared-test.mjs` |
|
|
626
645
|
| US-10 | C | `tests/stories/c-hang-recovery.test.mjs`, `tests/pool-hang-test.mjs`, `tests/pool-timer-test.mjs` |
|
|
@@ -654,6 +673,6 @@ The root-level suites under `tests/` are unit-style; the `tests/stories/` suites
|
|
|
654
673
|
the behavioural layer, written one epic per file with every test name carrying
|
|
655
674
|
its story id. Run everything with `npm test`.
|
|
656
675
|
|
|
657
|
-
|
|
676
|
+
41/41 stories covered by 380 test case(s); thinnest story has 1.
|
|
658
677
|
No story is untested. The scan covers the `tests/stories/` suites and the root-level
|
|
659
678
|
`tests/*-test.mjs` suites.
|
package/package.json
CHANGED
|
@@ -49,6 +49,13 @@ const FAILURE_KINDS = [
|
|
|
49
49
|
// and not-served failures are permanent for this model until a human acts.
|
|
50
50
|
const TRANSIENT_KINDS = new Set(["rate-limited", "credit", "timeout", "network", "server"])
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Failure kinds that vacate a pool slot for good: the model is gone, the
|
|
54
|
+
* credential is wrong, the terms are unsigned, or the balance is empty. Every
|
|
55
|
+
* other failure mode keeps the slot — benched, not deleted.
|
|
56
|
+
*/
|
|
57
|
+
export const HARD_DEAD_KINDS = new Set(["not-served", "credential", "agreement", "credit"])
|
|
58
|
+
|
|
52
59
|
export const isTransientKind = (kind) => TRANSIENT_KINDS.has(String(kind ?? "").toLowerCase())
|
|
53
60
|
|
|
54
61
|
export function classifyFailure({ status, reason, kind } = {}) {
|
|
@@ -127,9 +134,84 @@ const rowsOf = (providers) =>
|
|
|
127
134
|
|
|
128
135
|
const providerOf = (model) => String(model ?? "").split("/")[0]
|
|
129
136
|
|
|
137
|
+
/**
|
|
138
|
+
* Parse pool.txt slot lines into [{ model, weight, bucket?, context? }].
|
|
139
|
+
* Comments and blanks are ignored; weight defaults to 1.
|
|
140
|
+
*/
|
|
141
|
+
export function parsePoolSlots(text) {
|
|
142
|
+
const out = []
|
|
143
|
+
for (const line of String(text ?? "").split(/\r?\n/)) {
|
|
144
|
+
const code = line.split("#")[0].trim()
|
|
145
|
+
if (!code || !code.includes("/")) continue
|
|
146
|
+
const [model, weight] = code.split(/\s+/)
|
|
147
|
+
if (!model || !model.includes("/")) continue
|
|
148
|
+
const bucket = /b:([^\s]+)/.exec(code)?.[1]
|
|
149
|
+
const context = /c:(\d+)/.exec(code)?.[1]
|
|
150
|
+
out.push({
|
|
151
|
+
model,
|
|
152
|
+
weight: Number(weight) > 0 ? Number(weight) : 1,
|
|
153
|
+
...(bucket ? { bucket } : {}),
|
|
154
|
+
context: context ? Number(context) : null,
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
return out
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Pure structural verdict for a pool slot list (formatted lines or selection
|
|
162
|
+
* entries). The contract: an opencode-go head (every go slot first, weight 4),
|
|
163
|
+
* exactly one opencode tail (last, weight 2, b:opencode), POOL_SIZE slots.
|
|
164
|
+
* Returns { valid, problems[] } — valid covers head+tail; count is reported
|
|
165
|
+
* separately so a short-but-structured pool reads as short, not broken.
|
|
166
|
+
*/
|
|
167
|
+
export function poolStructureValid(slots) {
|
|
168
|
+
const problems = []
|
|
169
|
+
const items = typeof slots === "string" ? String(slots).split(/\r?\n/) : Array.isArray(slots) ? slots : []
|
|
170
|
+
const lines = items.map((entry) => {
|
|
171
|
+
if (typeof entry === "string") {
|
|
172
|
+
const parts = entry.split("#")[0].trim().split(/\s+/)
|
|
173
|
+
const bucket = /b:([^\s]+)/.exec(entry)?.[1]
|
|
174
|
+
return { model: parts[0] ?? "", weight: Number(parts[1]) || 0, bucket }
|
|
175
|
+
}
|
|
176
|
+
return { model: entry?.model ?? "", weight: Number(entry?.weight) || 0, bucket: entry?.bucket }
|
|
177
|
+
}).filter((entry) => entry.model.includes("/"))
|
|
178
|
+
const go = lines.filter((entry) => providerOf(entry.model) === "opencode-go")
|
|
179
|
+
const tails = lines.filter((entry) => providerOf(entry.model) === "opencode")
|
|
180
|
+
if (go.length === 0) problems.push("missing opencode-go head")
|
|
181
|
+
for (let i = 0; i < go.length; i++) {
|
|
182
|
+
if (lines[i] !== go[i]) {
|
|
183
|
+
problems.push("opencode-go slots must all come first")
|
|
184
|
+
break
|
|
185
|
+
}
|
|
186
|
+
if (go[i].weight !== 4) {
|
|
187
|
+
problems.push("opencode-go head slots must have weight 4")
|
|
188
|
+
break
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (tails.length !== 1) problems.push(`expected exactly one opencode tail, found ${tails.length}`)
|
|
192
|
+
else {
|
|
193
|
+
const tail = tails[0]
|
|
194
|
+
if (lines[lines.length - 1] !== tail) problems.push("opencode tail slot must be last")
|
|
195
|
+
if (tail.weight !== 2 || tail.bucket !== "opencode") problems.push("opencode tail must be weight 2 with b:opencode")
|
|
196
|
+
}
|
|
197
|
+
if (lines.length !== POOL_SIZE) problems.push(`expected ${POOL_SIZE} slots, found ${lines.length}`)
|
|
198
|
+
const structural = problems.filter((p) => !p.startsWith(`expected ${POOL_SIZE}`))
|
|
199
|
+
return { valid: structural.length === 0, problems, count: lines.length }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Hard-dead check for retention: only these failure kinds vacate a slot.
|
|
204
|
+
*/
|
|
205
|
+
export const isHardDeadRow = (row) => {
|
|
206
|
+
if (!row || row.ok !== false) return false
|
|
207
|
+
const kind = String(row.kind ?? classifyFailure({ status: row.status, reason: row.reason }) ?? "").toLowerCase()
|
|
208
|
+
return HARD_DEAD_KINDS.has(kind)
|
|
209
|
+
}
|
|
210
|
+
|
|
130
211
|
export function selectPool({
|
|
131
212
|
providers,
|
|
132
213
|
previousProviders,
|
|
214
|
+
currentSlots = [],
|
|
133
215
|
minContext = MIN_CONTEXT,
|
|
134
216
|
minScore = MIN_SCORE,
|
|
135
217
|
poolSize = POOL_SIZE,
|
|
@@ -237,14 +319,93 @@ export function selectPool({
|
|
|
237
319
|
|
|
238
320
|
const pickedZen = zen.slice(0, zenReserve)
|
|
239
321
|
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
322
|
+
const warnings = []
|
|
323
|
+
|
|
324
|
+
// Pool-level retention: a current member stays unless this run proved it
|
|
325
|
+
// hard-dead. A healthy-but-unselected model (caps, score floors) is NOT
|
|
326
|
+
// retained — the caps decided that. A transiently failed, unscanned, or
|
|
327
|
+
// budget-cut model is retained and benched in place by the writer; only a
|
|
328
|
+
// catalogue removal on a cleanly scanned provider vacates the slot. Head
|
|
329
|
+
// and tail are never vacated by a failed or partial scan.
|
|
330
|
+
const rowByModel = new Map(rows.map((row) => [row.model, row]))
|
|
331
|
+
const cleanlyScanned = (model) => {
|
|
332
|
+
const provider = providerOf(model)
|
|
333
|
+
return scannedProviders.has(provider) && !truncatedProviders.has(provider) && !erroredProviders.has(provider)
|
|
334
|
+
}
|
|
335
|
+
const placedModels = new Set([...go, ...cappedSmall, ...cappedLarge, ...pickedZen].map((row) => row.model))
|
|
336
|
+
const shouldRetain = (model) => {
|
|
337
|
+
if (!model.includes("/") || placedModels.has(model)) return false
|
|
338
|
+
const row = rowByModel.get(model)
|
|
339
|
+
if (row) return row.ok === false && !isHardDeadRow(row)
|
|
340
|
+
return !cleanlyScanned(model)
|
|
341
|
+
}
|
|
342
|
+
const retainedHead = []
|
|
343
|
+
const retainedMiddle = []
|
|
344
|
+
let retainedTail = null
|
|
345
|
+
for (const slot of Array.isArray(currentSlots) ? currentSlots : []) {
|
|
346
|
+
const model = String(slot?.model ?? "")
|
|
347
|
+
if (!shouldRetain(model)) continue
|
|
348
|
+
const weight = Number(slot?.weight)
|
|
349
|
+
if (providerOf(model) === "opencode-go") {
|
|
350
|
+
retainedHead.push({ model, weight: 4, context: slot?.context ?? null, retained: true })
|
|
351
|
+
} else if (providerOf(model) === "opencode") {
|
|
352
|
+
if (!retainedTail) retainedTail = { model, weight: 2, bucket: "opencode", context: slot?.context ?? null, retained: true }
|
|
353
|
+
} else {
|
|
354
|
+
retainedMiddle.push({
|
|
355
|
+
model,
|
|
356
|
+
weight: Number.isFinite(weight) && weight > 0 ? weight : 2,
|
|
357
|
+
...(slot?.bucket ? { bucket: slot.bucket } : {}),
|
|
358
|
+
context: slot?.context ?? null,
|
|
359
|
+
retained: true,
|
|
360
|
+
})
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const toSmall = (row) => ({ model: row.model, weight: 2, context: row.context ?? null })
|
|
365
|
+
const toLarge = (row) => ({ model: row.model, weight: 1, context: row.context ?? null })
|
|
366
|
+
const middle = [...pickedSmall.map(toSmall), ...pickedLarge.map(toLarge)]
|
|
367
|
+
for (const entry of retainedMiddle) {
|
|
368
|
+
if (!middle.some((existing) => existing.model === entry.model)) middle.push(entry)
|
|
369
|
+
}
|
|
370
|
+
if (retainedTail && !pickedZen.some((row) => providerOf(row.model) === "opencode")) {
|
|
371
|
+
middle.push({ ...retainedTail })
|
|
372
|
+
retainedTail = { ...retainedTail, placed: true }
|
|
373
|
+
}
|
|
374
|
+
const head = [
|
|
375
|
+
...retainedHead,
|
|
376
|
+
...go.filter((row) => !retainedHead.some((entry) => entry.model === row.model)).map((row) => ({ model: row.model, weight: 4, context: row.context ?? null })),
|
|
245
377
|
]
|
|
378
|
+
const tail = pickedZen.map((row) => ({ model: row.model, weight: 2, bucket: "opencode", context: row.context ?? null }))
|
|
379
|
+
let selection = [...head, ...middle, ...tail]
|
|
380
|
+
// Fit the contract size without ever vacating head or tail: trim the middle
|
|
381
|
+
// from the back (lowest score first), skipping retained slots. If even that
|
|
382
|
+
// cannot fit, the head wins and a warning says so.
|
|
383
|
+
while (selection.length > poolSize) {
|
|
384
|
+
let index = -1
|
|
385
|
+
for (let i = selection.length - 1; i >= 0; i--) {
|
|
386
|
+
const entry = selection[i]
|
|
387
|
+
if (!entry.retained && providerOf(entry.model) !== "opencode-go" && providerOf(entry.model) !== "opencode") {
|
|
388
|
+
index = i
|
|
389
|
+
break
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
if (index === -1) {
|
|
393
|
+
warnings.push(`pool head exceeds the contract size ${poolSize}; keeping every head slot`)
|
|
394
|
+
break
|
|
395
|
+
}
|
|
396
|
+
selection.splice(index, 1)
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const retainedCount = selection.filter((entry) => entry.retained).length
|
|
400
|
+
if (retainedCount > 0) {
|
|
401
|
+
warnings.push(
|
|
402
|
+
`retained ${retainedCount} current slot(s) benched in place: ${selection
|
|
403
|
+
.filter((entry) => entry.retained)
|
|
404
|
+
.map((entry) => entry.model)
|
|
405
|
+
.join(", ")}`,
|
|
406
|
+
)
|
|
407
|
+
}
|
|
246
408
|
|
|
247
|
-
const warnings = []
|
|
248
409
|
for (const entry of Array.isArray(providers) ? providers : []) {
|
|
249
410
|
if (entry?.error) {
|
|
250
411
|
warnings.push(`${entry.provider}: ${entry.error}`)
|
package/scripts/refresh-pool.mjs
CHANGED
|
@@ -20,10 +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"
|
|
26
|
+
import { fetchProviderStatus, statusDeprioritizedProviders } from "./provider-status.mjs"
|
|
27
|
+
import { POOL_SIZE, isTransientFailure, parsePoolSlots, poolStructureValid } 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")
|
|
@@ -194,14 +195,47 @@ function writeFileAtomic(path, text) {
|
|
|
194
195
|
renameSync(tmp, path)
|
|
195
196
|
}
|
|
196
197
|
|
|
197
|
-
function runScorer(max) {
|
|
198
|
+
function runScorer(max, extraArgs = []) {
|
|
198
199
|
const here = dirname(fileURLToPath(import.meta.url))
|
|
199
200
|
const scorer = resolve(here, "score-models.mjs")
|
|
200
201
|
if (!existsSync(scorer)) throw new Error(`scorer not found: ${scorer}`)
|
|
201
|
-
const args = [scorer, "--max", String(max)]
|
|
202
|
+
const args = [scorer, "--max", String(max), ...extraArgs]
|
|
202
203
|
execFileSync(process.execPath, args, { stdio: "inherit" })
|
|
203
204
|
}
|
|
204
205
|
|
|
206
|
+
/**
|
|
207
|
+
* Cheap health check: probe exactly the current pool members (no catalogue
|
|
208
|
+
* sweep). Every member answers → the pool stands as-is: no full scan, no
|
|
209
|
+
* retry, back to the 6-hour cadence. Anything else → false, and the caller
|
|
210
|
+
* runs a full scan to verify. Returns true when the pool stands.
|
|
211
|
+
*/
|
|
212
|
+
function runCanary({ currentPoolText, scorerArgs, max }) {
|
|
213
|
+
const members = parsePoolSlots(currentPoolText).map((slot) => slot.model)
|
|
214
|
+
if (members.length === 0) return false
|
|
215
|
+
const tmp = join(tmpdir(), `lacode-canary-${process.pid}.json`)
|
|
216
|
+
try {
|
|
217
|
+
runScorer(Math.max(max, members.length), [...scorerArgs, "--probe-models", members.join(","), "--out", tmp])
|
|
218
|
+
const payload = JSON.parse(readFileSync(tmp, "utf8"))
|
|
219
|
+
const answered = new Set()
|
|
220
|
+
for (const entry of payload?.providers ?? []) {
|
|
221
|
+
for (const row of entry?.rows ?? []) {
|
|
222
|
+
if (row?.model && row?.ok === true) answered.add(row.model)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
const missing = members.filter((model) => !answered.has(model))
|
|
226
|
+
if (missing.length === 0) {
|
|
227
|
+
console.log(`canary healthy: all ${members.length} pool member(s) answered; pool stands, no full scan`)
|
|
228
|
+
return true
|
|
229
|
+
}
|
|
230
|
+
console.log(`canary: ${missing.length} member(s) not answering (${missing.join(", ")}); running a full scan`)
|
|
231
|
+
return false
|
|
232
|
+
} finally {
|
|
233
|
+
try {
|
|
234
|
+
rmSync(tmp, { force: true })
|
|
235
|
+
} catch {}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
205
239
|
/**
|
|
206
240
|
* Count the usable model slots in a pool.txt text (comments and blanks ignored).
|
|
207
241
|
*/
|
|
@@ -210,22 +244,103 @@ export function poolSlotCount(text) {
|
|
|
210
244
|
}
|
|
211
245
|
|
|
212
246
|
/**
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
|
|
217
|
-
|
|
247
|
+
* Bench-in-place file layout. Must match src/lib/pool.ts: the runtime treats
|
|
248
|
+
* these files as flat cooling records (never strikes) and re-admits a model
|
|
249
|
+
* the moment a probe watches it answer.
|
|
250
|
+
*/
|
|
251
|
+
export const SCAN_BENCH_REASON = "scan-kept"
|
|
252
|
+
export const SCAN_BENCH_WINDOW_MS = 45 * 60 * 1000
|
|
253
|
+
export const scanBenchDir = (home = HOME) => join(home, ".local", "share", "opencode", "agent-pool")
|
|
254
|
+
export const scanBenchFile = (model) => `limit.${String(model).replace(/[^a-zA-Z0-9._-]/g, "__")}.json`
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Pure bench plan for the current pool members. A member whose current row
|
|
258
|
+
* failed transiently is benched until its stated reset (else 45 minutes); a
|
|
259
|
+
* member that answered has any stale bench cleared; an unscanned member is
|
|
260
|
+
* left alone for lack of evidence. Only pool members are ever touched.
|
|
261
|
+
*/
|
|
262
|
+
export function computeScanBench({ selectionModels, providers, now = Date.now() }) {
|
|
263
|
+
const currentByModel = new Map()
|
|
264
|
+
for (const entry of Array.isArray(providers) ? providers : []) {
|
|
265
|
+
for (const row of Array.isArray(entry?.rows) ? entry.rows : []) {
|
|
266
|
+
if (row?.model && !currentByModel.has(row.model)) currentByModel.set(row.model, row)
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
const bench = []
|
|
270
|
+
const clear = []
|
|
271
|
+
for (const model of selectionModels ?? []) {
|
|
272
|
+
const row = currentByModel.get(model)
|
|
273
|
+
if (!row) continue
|
|
274
|
+
if (row.ok !== false) {
|
|
275
|
+
clear.push(model)
|
|
276
|
+
continue
|
|
277
|
+
}
|
|
278
|
+
if (!isTransientFailure(row)) continue
|
|
279
|
+
const at = Number(row.retryAfter)
|
|
280
|
+
bench.push({
|
|
281
|
+
model,
|
|
282
|
+
until: Number.isFinite(at) && at > now ? at : now + SCAN_BENCH_WINDOW_MS,
|
|
283
|
+
kind: String(row.kind ?? "unknown"),
|
|
284
|
+
})
|
|
285
|
+
}
|
|
286
|
+
return { bench, clear }
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Apply a bench plan with atomic writes; every failure is best-effort. */
|
|
290
|
+
export function applyScanBench({ dir, bench, clear }) {
|
|
291
|
+
const applied = { benched: [], cleared: [] }
|
|
292
|
+
try {
|
|
293
|
+
mkdirSync(dir, { recursive: true })
|
|
294
|
+
} catch {}
|
|
295
|
+
for (const { model, until } of bench ?? []) {
|
|
296
|
+
try {
|
|
297
|
+
const record = { model, until, strikes: 1, reason: SCAN_BENCH_REASON, last: Date.now() }
|
|
298
|
+
const tmp = join(dir, `${scanBenchFile(model)}.tmp-${process.pid}`)
|
|
299
|
+
writeFileSync(tmp, JSON.stringify(record))
|
|
300
|
+
renameSync(tmp, join(dir, scanBenchFile(model)))
|
|
301
|
+
applied.benched.push(model)
|
|
302
|
+
} catch {}
|
|
303
|
+
}
|
|
304
|
+
for (const model of clear ?? []) {
|
|
305
|
+
try {
|
|
306
|
+
rmSync(join(dir, scanBenchFile(model)), { force: true })
|
|
307
|
+
applied.cleared.push(model)
|
|
308
|
+
} catch {}
|
|
309
|
+
}
|
|
310
|
+
return applied
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Pure fail-closed guard. A refresh must never break the pool's structure or
|
|
315
|
+
* replace a working pool with a degenerate selection: the new pool must keep
|
|
316
|
+
* the opencode-go head and the opencode tail the current pool has, and hold at
|
|
317
|
+
* least half of POOL_SIZE. The baseline is the contract size, never the
|
|
318
|
+
* current file — a previously shrunk pool can always grow back, but no write
|
|
319
|
+
* may shrink or behead it. There is no previous pool on a first-ever run, so
|
|
320
|
+
* there is nothing to protect and the guard passes. `--force-write` is the
|
|
321
|
+
* human override for a genuinely changed catalogue.
|
|
218
322
|
*/
|
|
219
|
-
export function shouldKeepPool({ currentPoolText,
|
|
323
|
+
export function shouldKeepPool({ currentPoolText, newSlots }) {
|
|
220
324
|
const prev = poolSlotCount(currentPoolText ?? "")
|
|
221
325
|
if (prev <= 0) return { keep: false, prev, threshold: 0, reason: "no previous pool to protect" }
|
|
222
|
-
const
|
|
223
|
-
|
|
326
|
+
const slots = Array.isArray(newSlots) ? newSlots : []
|
|
327
|
+
const threshold = Math.max(MIN_POOL_SLOTS, Math.floor(POOL_SIZE / 2))
|
|
328
|
+
const currentCheck = poolStructureValid(currentPoolText)
|
|
329
|
+
const newCheck = poolStructureValid(slots)
|
|
330
|
+
if (currentCheck.valid && !newCheck.valid) {
|
|
331
|
+
return {
|
|
332
|
+
keep: true,
|
|
333
|
+
prev,
|
|
334
|
+
threshold,
|
|
335
|
+
reason: `new selection breaks the pool structure (${newCheck.problems.join("; ")}); keeping the previous pool`,
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
if (slots.filter(isSlotLine).length >= threshold) return { keep: false, prev, threshold, reason: "" }
|
|
224
339
|
return {
|
|
225
340
|
keep: true,
|
|
226
341
|
prev,
|
|
227
342
|
threshold,
|
|
228
|
-
reason: `new selection has ${
|
|
343
|
+
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`,
|
|
229
344
|
}
|
|
230
345
|
}
|
|
231
346
|
|
|
@@ -251,6 +366,7 @@ export function scheduleRetryIn(seconds, options = {}) {
|
|
|
251
366
|
const scriptPath = options.scriptPath ?? fileURLToPath(import.meta.url)
|
|
252
367
|
const nodePath = options.nodePath ?? process.execPath
|
|
253
368
|
const home = options.home ?? HOME
|
|
369
|
+
const extraArgs = Array.isArray(options.args) ? options.args : []
|
|
254
370
|
const nowMs = (options.now ?? Date.now)()
|
|
255
371
|
const path = `${home}/.opencode/bin:/usr/local/bin:/usr/bin:/bin`
|
|
256
372
|
const unit = `lacode-refresh-pool-retry-${Math.floor(nowMs / 1000).toString(36)}`
|
|
@@ -266,6 +382,7 @@ export function scheduleRetryIn(seconds, options = {}) {
|
|
|
266
382
|
`--setenv=PATH=${path}`,
|
|
267
383
|
nodePath,
|
|
268
384
|
scriptPath,
|
|
385
|
+
...extraArgs,
|
|
269
386
|
],
|
|
270
387
|
{ stdio: "ignore" },
|
|
271
388
|
)
|
|
@@ -287,9 +404,11 @@ export function scheduleRetry(retry, options = {}) {
|
|
|
287
404
|
return scheduled
|
|
288
405
|
}
|
|
289
406
|
|
|
290
|
-
/** Schedule a recheck on the degraded cadence (default 5 minutes).
|
|
407
|
+
/** Schedule a recheck on the degraded cadence (default 5 minutes). Retries run
|
|
408
|
+
* a cheap canary first and only fall back to a full scan when a pool member
|
|
409
|
+
* stops answering. */
|
|
291
410
|
export function scheduleRetrySoon(seconds = DEGRADED_RETRY_S, options = {}) {
|
|
292
|
-
return scheduleRetryIn(seconds, options)
|
|
411
|
+
return scheduleRetryIn(seconds, { ...options, args: [...(options.args ?? []), "--canary"] })
|
|
293
412
|
}
|
|
294
413
|
|
|
295
414
|
export function renderTimerUnits(input = {}) {
|
|
@@ -380,17 +499,24 @@ function usage() {
|
|
|
380
499
|
systemctl --user start lacode-refresh-pool.service
|
|
381
500
|
--print print the proposed pool.txt and make no changes (alias: --dry-run)
|
|
382
501
|
--dry-run same as --print
|
|
502
|
+
--canary probe only the current pool members; run a full scan only
|
|
503
|
+
when one stops answering (used by degraded rechecks)
|
|
383
504
|
--max N models per provider passed to the scorer (default: 50)
|
|
384
505
|
--scores PATH scores file (default: ${DEFAULT_SCORES_PATH})
|
|
385
506
|
--pool PATH pool file to read/compare/write (default: ${DEFAULT_POOL_PATH})
|
|
507
|
+
--force-write write the new selection even when the fail-closed guard would
|
|
508
|
+
keep the previous pool (human override for a changed catalogue)
|
|
386
509
|
-h, --help this message
|
|
387
510
|
|
|
388
|
-
Degradation behaviour: before scanning, the refresh
|
|
389
|
-
https://status.opencode.ai/api/status.
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
the
|
|
393
|
-
|
|
511
|
+
Degradation behaviour: before scanning, the refresh reads
|
|
512
|
+
https://status.opencode.ai/api/status. The page is a hint, never a verdict:
|
|
513
|
+
sick providers are only probed last, and nothing is ever skipped or benched
|
|
514
|
+
because of it. A canary (--canary, used by every degraded recheck) probes only
|
|
515
|
+
the current pool members and runs a full scan solely when one stops answering.
|
|
516
|
+
After a scan, a selection that breaks the pool structure or falls below half
|
|
517
|
+
the contract pool size (minimum ${MIN_POOL_SLOTS} slots) is never written; the
|
|
518
|
+
previous pool is kept, transiently failed members are still benched in place,
|
|
519
|
+
and a 5-minute canary recheck is scheduled. A retry is always rechecked
|
|
394
520
|
within 5 minutes while anything failed, even when the stated reset is hours out.`)
|
|
395
521
|
}
|
|
396
522
|
|
|
@@ -404,6 +530,7 @@ async function main() {
|
|
|
404
530
|
const fromScores = argv.includes("--from-scores")
|
|
405
531
|
const printOnly = argv.includes("--print") || argv.includes("--dry-run")
|
|
406
532
|
const noRetry = argv.includes("--no-retry")
|
|
533
|
+
const forceWrite = argv.includes("--force-write")
|
|
407
534
|
const value = (flag, fallback) => {
|
|
408
535
|
const hit = argv.find((a) => a === flag || a.startsWith(`${flag}=`))
|
|
409
536
|
if (!hit) return fallback
|
|
@@ -422,41 +549,46 @@ async function main() {
|
|
|
422
549
|
}
|
|
423
550
|
|
|
424
551
|
const currentPoolText = readTextFile(poolPath)
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
//
|
|
428
|
-
//
|
|
429
|
-
//
|
|
430
|
-
//
|
|
431
|
-
|
|
432
|
-
|
|
552
|
+
const canary = argv.includes("--canary")
|
|
553
|
+
|
|
554
|
+
// Live status is a hint, never a verdict. Gating services that report down
|
|
555
|
+
// or degraded only reorder probes (sick providers last) so they cannot eat
|
|
556
|
+
// the scan budget. Nothing is ever skipped or benched because of the page —
|
|
557
|
+
// only real probe evidence benches a model, and the write guard below
|
|
558
|
+
// protects the pool if the scan comes back degenerate.
|
|
559
|
+
let deprioritized = []
|
|
560
|
+
if (!printOnly && !fromScores) {
|
|
433
561
|
try {
|
|
434
|
-
|
|
562
|
+
deprioritized = statusDeprioritizedProviders(await fetchProviderStatus())
|
|
435
563
|
} catch (error) {
|
|
436
|
-
|
|
564
|
+
console.log(`live status unreadable (${String(error?.message ?? error)}); scanning in default order`)
|
|
437
565
|
}
|
|
438
|
-
if (
|
|
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
|
-
)
|
|
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
|
|
566
|
+
if (deprioritized.length > 0) {
|
|
567
|
+
console.log(`status hint: probing ${deprioritized.join(", ")} last`)
|
|
452
568
|
}
|
|
453
569
|
}
|
|
570
|
+
const scorerArgs = [`--current-pool`, poolPath]
|
|
571
|
+
if (deprioritized.length > 0) scorerArgs.push(`--deprioritize-providers`, deprioritized.join(","))
|
|
572
|
+
|
|
573
|
+
const runFullScan = () => runScorer(max, scorerArgs)
|
|
454
574
|
|
|
455
575
|
if (!fromScores) {
|
|
456
576
|
try {
|
|
457
|
-
|
|
577
|
+
if (canary) {
|
|
578
|
+
runCanary({ poolPath, currentPoolText, scorerArgs, max })
|
|
579
|
+
} else {
|
|
580
|
+
runFullScan()
|
|
581
|
+
}
|
|
458
582
|
} catch (error) {
|
|
459
583
|
console.error(`scorer failed; pool.txt not written: ${String(error?.message ?? error)}`)
|
|
584
|
+
if (!printOnly && !noRetry) {
|
|
585
|
+
const scheduled = scheduleRetrySoon()
|
|
586
|
+
console.log(
|
|
587
|
+
scheduled.scheduled
|
|
588
|
+
? `scheduled degraded recheck in ${scheduled.seconds}s`
|
|
589
|
+
: `degraded recheck not scheduled: ${scheduled.reason}`,
|
|
590
|
+
)
|
|
591
|
+
}
|
|
460
592
|
process.exitCode = 1
|
|
461
593
|
return
|
|
462
594
|
}
|
|
@@ -476,12 +608,35 @@ async function main() {
|
|
|
476
608
|
built = buildPool({ scores, currentPoolText, now: new Date() })
|
|
477
609
|
} catch (error) {
|
|
478
610
|
console.error(`refresh refused (fail closed): ${String(error?.message ?? error)}`)
|
|
611
|
+
if (!printOnly && !noRetry) {
|
|
612
|
+
const scheduled = scheduleRetrySoon()
|
|
613
|
+
console.log(
|
|
614
|
+
scheduled.scheduled
|
|
615
|
+
? `scheduled degraded recheck in ${scheduled.seconds}s`
|
|
616
|
+
: `degraded recheck not scheduled: ${scheduled.reason}`,
|
|
617
|
+
)
|
|
618
|
+
}
|
|
479
619
|
process.exitCode = 1
|
|
480
620
|
return
|
|
481
621
|
}
|
|
482
622
|
|
|
483
623
|
const oldSlotCount = poolSlotCount(currentPoolText)
|
|
484
624
|
const newSlotCount = built.slots.filter(isSlotLine).length
|
|
625
|
+
|
|
626
|
+
// Bench-in-place: a pool member this run failed transiently keeps its slot
|
|
627
|
+
// and gets a flat cooling record the runtime already honours; a member that
|
|
628
|
+
// answered has any stale bench cleared. Unscanned members are left alone.
|
|
629
|
+
// Runs on the write path and the refusal path alike — a kept pool still
|
|
630
|
+
// learns what the scan found.
|
|
631
|
+
if (!printOnly) {
|
|
632
|
+
const plan = computeScanBench({ selectionModels: built.chosen.selected, providers: scores.providers })
|
|
633
|
+
const applied = applyScanBench({ dir: scanBenchDir(), bench: plan.bench, clear: plan.clear })
|
|
634
|
+
if (applied.benched.length > 0) {
|
|
635
|
+
const detail = plan.bench.map((entry) => `${entry.model} (${entry.kind})`).join(", ")
|
|
636
|
+
console.log(`scan benched in place: ${detail}`)
|
|
637
|
+
}
|
|
638
|
+
if (applied.cleared.length > 0) console.log(`scan cleared stale benches: ${applied.cleared.join(", ")}`)
|
|
639
|
+
}
|
|
485
640
|
const dropped = scores.providers.flatMap((entry) => (Array.isArray(entry?.excluded) ? entry.excluded : []))
|
|
486
641
|
const summary = [
|
|
487
642
|
`selected [${built.chosen.selected.join(", ")}]`,
|
|
@@ -491,8 +646,9 @@ async function main() {
|
|
|
491
646
|
|
|
492
647
|
// Fail closed on a degenerate selection: keep the previous pool, say so
|
|
493
648
|
// loudly, and recheck in 5 minutes instead of waiting out a long reset.
|
|
494
|
-
|
|
495
|
-
|
|
649
|
+
// --force-write is the human override for a genuinely changed catalogue.
|
|
650
|
+
if (!printOnly && !forceWrite) {
|
|
651
|
+
const kept = shouldKeepPool({ currentPoolText, newSlots: built.slots })
|
|
496
652
|
if (kept.keep) {
|
|
497
653
|
console.error(`refresh refused (fail closed): ${kept.reason}`)
|
|
498
654
|
if (!noRetry) {
|
package/scripts/score-models.mjs
CHANGED
|
@@ -46,13 +46,14 @@ import {
|
|
|
46
46
|
classifyFailure,
|
|
47
47
|
isTransientFailure,
|
|
48
48
|
isTransientKind,
|
|
49
|
+
parsePoolSlots,
|
|
49
50
|
providerRank,
|
|
50
51
|
retryAfterMs,
|
|
51
52
|
selectPool,
|
|
52
53
|
} from "./pool-selection.mjs"
|
|
53
54
|
|
|
54
55
|
const AUTH = join(homedir(), ".local", "share", "opencode", "auth.json")
|
|
55
|
-
const
|
|
56
|
+
const DEFAULT_OUT = join(homedir(), ".config", "lacode", "model-scores.json")
|
|
56
57
|
// No default provider list: `opencode models --verbose` with no argument returns
|
|
57
58
|
// every provider opencode knows about, and that is what we scan by default.
|
|
58
59
|
const SESSION_PROVIDERS = new Set(["opencode", "opencode-go"])
|
|
@@ -100,11 +101,18 @@ const value = (flag, fallback) => {
|
|
|
100
101
|
console.log(`Usage: npm run score -- [options]
|
|
101
102
|
|
|
102
103
|
--providers a,b restrict the scan to these providers (default: all configured)
|
|
104
|
+
--probe-models a,b probe exactly these provider/model ids (canary: skips the
|
|
105
|
+
catalogue scan for everything else; unscanned providers keep
|
|
106
|
+
their previous rows)
|
|
107
|
+
--deprioritize-providers a,b probe these providers last (a live status hint;
|
|
108
|
+
never excludes anything, ordering only)
|
|
109
|
+
--current-pool PATH feed pool.txt as the current pool so selection retains
|
|
110
|
+
members this run could not reach (default: no retention)
|
|
103
111
|
--max N probe at most N candidate models per provider (default: 50)
|
|
104
112
|
--min-context N exclude models whose context limit is below N (default: ${MIN_CONTEXT})
|
|
105
113
|
--min-score N do not rank models scoring below N (default: ${MIN_SCORE})
|
|
106
114
|
--free-only only probe catalogue cost 0/0 models on every provider
|
|
107
|
-
--timeout MS per-request deadline in ms (default:
|
|
115
|
+
--timeout MS per-request deadline in ms (default: 12000)
|
|
108
116
|
--delay MS pause between requests in ms (default: 300)
|
|
109
117
|
--budget-min N stop starting new probes after N minutes and write what was
|
|
110
118
|
measured; providers not scanned keep their previous rows
|
|
@@ -126,13 +134,24 @@ Providers with no direct HTTP endpoint (or an unexpanded \${...} URL) are probed
|
|
|
126
134
|
|
|
127
135
|
const providerArg = value("--providers", null)
|
|
128
136
|
const providers = providerArg ? String(providerArg).split(",").map((s) => s.trim()).filter(Boolean) : null
|
|
137
|
+
const probeModelsArg = value("--probe-models", null)
|
|
138
|
+
const probeModels = probeModelsArg ? new Set(String(probeModelsArg).split(",").map((s) => s.trim()).filter(Boolean)) : null
|
|
139
|
+
const deprioritizeArg = value("--deprioritize-providers", null)
|
|
140
|
+
const deprioritizedProviders = new Set(
|
|
141
|
+
deprioritizeArg ? String(deprioritizeArg).split(",").map((s) => s.trim()).filter(Boolean) : [],
|
|
142
|
+
)
|
|
143
|
+
// probe --probe-models ids absent from every catalogue. Filled during the
|
|
144
|
+
// catalogue filter above; emitted as synthetic not-served rows after probing.
|
|
145
|
+
const missingProbeModels = []
|
|
146
|
+
const currentPoolPath = value("--current-pool", null)
|
|
129
147
|
const maxPerProvider = Math.max(1, Number(value("--max", "50")) || 50)
|
|
130
148
|
const minContext = Math.max(0, Number(value("--min-context", String(MIN_CONTEXT))) || MIN_CONTEXT)
|
|
131
149
|
const minScore = Math.max(0, Number(value("--min-score", String(MIN_SCORE))) || MIN_SCORE)
|
|
132
|
-
const timeoutMs = Math.max(1_000, Number(value("--timeout", "
|
|
150
|
+
const timeoutMs = Math.max(1_000, Number(value("--timeout", "12000")) || 12_000)
|
|
133
151
|
const delayMs = Math.max(0, Number(value("--delay", "300")) || 300)
|
|
134
152
|
const budgetMin = Math.max(1, Number(value("--budget-min", "30")) || 30)
|
|
135
153
|
const budgetMs = budgetMin * 60_000
|
|
154
|
+
const OUT = value("--out", DEFAULT_OUT)
|
|
136
155
|
const writeResults = !has("--no-write")
|
|
137
156
|
const freeOnlyFlag = has("--free-only")
|
|
138
157
|
|
|
@@ -570,12 +589,35 @@ const pad = (s, n) => String(s).padEnd(n)
|
|
|
570
589
|
const padL = (s, n) => String(s).padStart(n)
|
|
571
590
|
|
|
572
591
|
async function main() {
|
|
573
|
-
|
|
592
|
+
let scanned = providers ? providers.map((provider) => freeModels(provider)) : freeModelsAll()
|
|
574
593
|
// Core providers first, then the free-only middle tier, then the slow paid
|
|
575
|
-
// catalogues
|
|
594
|
+
// catalogues — and explicitly deprioritized providers last. Ordering only;
|
|
595
|
+
// every provider is still probed if time allows.
|
|
576
596
|
scanned.sort(
|
|
577
|
-
(a, b) =>
|
|
597
|
+
(a, b) =>
|
|
598
|
+
Number(deprioritizedProviders.has(a.provider)) - Number(deprioritizedProviders.has(b.provider)) ||
|
|
599
|
+
providerRank(a.provider) - providerRank(b.provider) ||
|
|
600
|
+
String(a.provider).localeCompare(String(b.provider)),
|
|
578
601
|
)
|
|
602
|
+
const currentSlots = (() => {
|
|
603
|
+
if (!currentPoolPath) return []
|
|
604
|
+
try {
|
|
605
|
+
return parsePoolSlots(readFileSync(currentPoolPath, "utf8"))
|
|
606
|
+
} catch {
|
|
607
|
+
return []
|
|
608
|
+
}
|
|
609
|
+
})()
|
|
610
|
+
if (probeModels && probeModels.size > 0) {
|
|
611
|
+
const seen = new Set()
|
|
612
|
+
scanned = scanned.flatMap((entry) => {
|
|
613
|
+
if (entry.error) return []
|
|
614
|
+
const models = (entry.models ?? []).filter((candidate) => probeModels.has(candidate.model))
|
|
615
|
+
models.forEach((candidate) => seen.add(candidate.model))
|
|
616
|
+
if (models.length === 0) return []
|
|
617
|
+
return [{ ...entry, models }]
|
|
618
|
+
})
|
|
619
|
+
missingProbeModels.push(...[...probeModels].filter((model) => !seen.has(model)))
|
|
620
|
+
}
|
|
579
621
|
const results = []
|
|
580
622
|
const deadline = Date.now() + budgetMs
|
|
581
623
|
const skippedProviders = []
|
|
@@ -655,6 +697,32 @@ async function main() {
|
|
|
655
697
|
}
|
|
656
698
|
stopSessionServer()
|
|
657
699
|
|
|
700
|
+
// A canary model absent from every catalogue is not "unscanned" — it is
|
|
701
|
+
// gone. A synthetic not-served row makes the selector drop it (hard-dead)
|
|
702
|
+
// instead of retaining it blindly.
|
|
703
|
+
for (const model of missingProbeModels) {
|
|
704
|
+
const slash = model.indexOf("/")
|
|
705
|
+
const provider = slash === -1 ? "unknown" : model.slice(0, slash)
|
|
706
|
+
const row = {
|
|
707
|
+
provider,
|
|
708
|
+
model,
|
|
709
|
+
url: "",
|
|
710
|
+
apiId: slash === -1 ? model : model.slice(slash + 1),
|
|
711
|
+
context: null,
|
|
712
|
+
output: null,
|
|
713
|
+
ok: false,
|
|
714
|
+
ms: null,
|
|
715
|
+
reason: "not in provider catalogue (model-missing)",
|
|
716
|
+
status: 404,
|
|
717
|
+
kind: "not-served",
|
|
718
|
+
score: null,
|
|
719
|
+
retryAfter: null,
|
|
720
|
+
}
|
|
721
|
+
const entry = results.find((candidate) => candidate.provider === provider)
|
|
722
|
+
if (entry) entry.rows.push(row)
|
|
723
|
+
else results.push({ provider, error: null, rows: [row], excluded: [], truncated: false, degraded: false })
|
|
724
|
+
}
|
|
725
|
+
|
|
658
726
|
// Per-provider ranked tables.
|
|
659
727
|
for (const entry of results) {
|
|
660
728
|
if (entry.error || entry.rows.length === 0) continue
|
|
@@ -705,6 +773,7 @@ async function main() {
|
|
|
705
773
|
excluded: entry.excluded,
|
|
706
774
|
})),
|
|
707
775
|
previousProviders: previousScores?.providers,
|
|
776
|
+
currentSlots,
|
|
708
777
|
minContext,
|
|
709
778
|
minScore,
|
|
710
779
|
})
|