@lawaty/lacode 2.3.0-beta.4 → 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 +14 -0
- package/dist/index.js +93 -51
- package/dist/pool.js +3 -1
- package/docs/USER-STORIES.md +18 -1
- package/package.json +1 -1
- package/scripts/pool-selection.mjs +167 -6
- package/scripts/refresh-pool.mjs +155 -80
- package/scripts/score-models.mjs +73 -4
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,20 @@ 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
|
+
|
|
12
26
|
## [2.3.0-beta.4] — status never benches, structure is the contract
|
|
13
27
|
|
|
14
28
|
### Fixed
|
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
|
@@ -202,6 +202,22 @@ A refresh once replaced a healthy ten-slot pool with a two-slot scan because not
|
|
|
202
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.
|
|
203
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.
|
|
204
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
|
+
|
|
205
221
|
---
|
|
206
222
|
|
|
207
223
|
## Epic C — Hang detection and auto-recovery
|
|
@@ -623,6 +639,7 @@ table was filled in.
|
|
|
623
639
|
| US-31 | B | `tests/stories/b-provider-resilience.test.mjs`, `tests/stories/c-hang-recovery.test.mjs` |
|
|
624
640
|
| US-39 | B | `tests/stories/l-pool-refresh.test.mjs` |
|
|
625
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` |
|
|
626
643
|
| US-8 | C | `tests/stories/c-hang-recovery.test.mjs`, `tests/pool-hang-test.mjs` |
|
|
627
644
|
| US-9 | C | `tests/stories/c-hang-recovery.test.mjs`, `tests/pool-hang-test.mjs`, `tests/pool-shared-test.mjs` |
|
|
628
645
|
| US-10 | C | `tests/stories/c-hang-recovery.test.mjs`, `tests/pool-hang-test.mjs`, `tests/pool-timer-test.mjs` |
|
|
@@ -656,6 +673,6 @@ The root-level suites under `tests/` are unit-style; the `tests/stories/` suites
|
|
|
656
673
|
the behavioural layer, written one epic per file with every test name carrying
|
|
657
674
|
its story id. Run everything with `npm test`.
|
|
658
675
|
|
|
659
|
-
|
|
676
|
+
41/41 stories covered by 380 test case(s); thinnest story has 1.
|
|
660
677
|
No story is untested. The scan covers the `tests/stories/` suites and the root-level
|
|
661
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,11 +20,11 @@
|
|
|
20
20
|
|
|
21
21
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"
|
|
22
22
|
import { execFileSync } from "node:child_process"
|
|
23
|
-
import { homedir, userInfo } from "node:os"
|
|
23
|
+
import { homedir, tmpdir, userInfo } from "node:os"
|
|
24
24
|
import { dirname, join, resolve } from "node:path"
|
|
25
25
|
import { fileURLToPath } from "node:url"
|
|
26
|
-
import { fetchProviderStatus } from "./provider-status.mjs"
|
|
27
|
-
import { POOL_SIZE } from "./pool-selection.mjs"
|
|
26
|
+
import { fetchProviderStatus, statusDeprioritizedProviders } from "./provider-status.mjs"
|
|
27
|
+
import { POOL_SIZE, isTransientFailure, parsePoolSlots, poolStructureValid } from "./pool-selection.mjs"
|
|
28
28
|
|
|
29
29
|
const HOME = homedir()
|
|
30
30
|
export const DEFAULT_SCORES_PATH = join(HOME, ".config", "lacode", "model-scores.json")
|
|
@@ -195,14 +195,47 @@ function writeFileAtomic(path, text) {
|
|
|
195
195
|
renameSync(tmp, path)
|
|
196
196
|
}
|
|
197
197
|
|
|
198
|
-
function runScorer(max) {
|
|
198
|
+
function runScorer(max, extraArgs = []) {
|
|
199
199
|
const here = dirname(fileURLToPath(import.meta.url))
|
|
200
200
|
const scorer = resolve(here, "score-models.mjs")
|
|
201
201
|
if (!existsSync(scorer)) throw new Error(`scorer not found: ${scorer}`)
|
|
202
|
-
const args = [scorer, "--max", String(max)]
|
|
202
|
+
const args = [scorer, "--max", String(max), ...extraArgs]
|
|
203
203
|
execFileSync(process.execPath, args, { stdio: "inherit" })
|
|
204
204
|
}
|
|
205
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
|
+
|
|
206
239
|
/**
|
|
207
240
|
* Count the usable model slots in a pool.txt text (comments and blanks ignored).
|
|
208
241
|
*/
|
|
@@ -210,40 +243,71 @@ export function poolSlotCount(text) {
|
|
|
210
243
|
return splitHeaderAndSlots(text).slots.filter(isSlotLine).length
|
|
211
244
|
}
|
|
212
245
|
|
|
213
|
-
/**
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
export
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
const last = slots[slots.length - 1].split("#")[0].trim()
|
|
223
|
-
return last.startsWith("opencode/") && /(^|\s)b:opencode(\s|$)/.test(last)
|
|
224
|
-
}
|
|
246
|
+
/**
|
|
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`
|
|
225
255
|
|
|
226
256
|
/**
|
|
227
|
-
* Pure
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
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.
|
|
231
261
|
*/
|
|
232
|
-
export function
|
|
233
|
-
|
|
234
|
-
|
|
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
|
+
}
|
|
235
268
|
}
|
|
236
|
-
const
|
|
237
|
-
|
|
238
|
-
|
|
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
|
+
})
|
|
239
285
|
}
|
|
240
|
-
|
|
241
|
-
|
|
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 {}
|
|
242
303
|
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
304
|
+
for (const model of clear ?? []) {
|
|
305
|
+
try {
|
|
306
|
+
rmSync(join(dir, scanBenchFile(model)), { force: true })
|
|
307
|
+
applied.cleared.push(model)
|
|
308
|
+
} catch {}
|
|
246
309
|
}
|
|
310
|
+
return applied
|
|
247
311
|
}
|
|
248
312
|
|
|
249
313
|
/**
|
|
@@ -261,32 +325,22 @@ export function shouldKeepPool({ currentPoolText, newSlots }) {
|
|
|
261
325
|
if (prev <= 0) return { keep: false, prev, threshold: 0, reason: "no previous pool to protect" }
|
|
262
326
|
const slots = Array.isArray(newSlots) ? newSlots : []
|
|
263
327
|
const threshold = Math.max(MIN_POOL_SLOTS, Math.floor(POOL_SIZE / 2))
|
|
264
|
-
|
|
328
|
+
const currentCheck = poolStructureValid(currentPoolText)
|
|
329
|
+
const newCheck = poolStructureValid(slots)
|
|
330
|
+
if (currentCheck.valid && !newCheck.valid) {
|
|
265
331
|
return {
|
|
266
332
|
keep: true,
|
|
267
333
|
prev,
|
|
268
334
|
threshold,
|
|
269
|
-
reason: `new selection
|
|
335
|
+
reason: `new selection breaks the pool structure (${newCheck.problems.join("; ")}); keeping the previous pool`,
|
|
270
336
|
}
|
|
271
337
|
}
|
|
272
|
-
|
|
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: "" }
|
|
338
|
+
if (slots.filter(isSlotLine).length >= threshold) return { keep: false, prev, threshold, reason: "" }
|
|
285
339
|
return {
|
|
286
340
|
keep: true,
|
|
287
341
|
prev,
|
|
288
342
|
threshold,
|
|
289
|
-
reason: `new selection has ${slots.length} usable slot(s), below the keep threshold of ${threshold} (contract pool size ${POOL_SIZE}); refusing to shrink the pool`,
|
|
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`,
|
|
290
344
|
}
|
|
291
345
|
}
|
|
292
346
|
|
|
@@ -312,6 +366,7 @@ export function scheduleRetryIn(seconds, options = {}) {
|
|
|
312
366
|
const scriptPath = options.scriptPath ?? fileURLToPath(import.meta.url)
|
|
313
367
|
const nodePath = options.nodePath ?? process.execPath
|
|
314
368
|
const home = options.home ?? HOME
|
|
369
|
+
const extraArgs = Array.isArray(options.args) ? options.args : []
|
|
315
370
|
const nowMs = (options.now ?? Date.now)()
|
|
316
371
|
const path = `${home}/.opencode/bin:/usr/local/bin:/usr/bin:/bin`
|
|
317
372
|
const unit = `lacode-refresh-pool-retry-${Math.floor(nowMs / 1000).toString(36)}`
|
|
@@ -327,6 +382,7 @@ export function scheduleRetryIn(seconds, options = {}) {
|
|
|
327
382
|
`--setenv=PATH=${path}`,
|
|
328
383
|
nodePath,
|
|
329
384
|
scriptPath,
|
|
385
|
+
...extraArgs,
|
|
330
386
|
],
|
|
331
387
|
{ stdio: "ignore" },
|
|
332
388
|
)
|
|
@@ -348,9 +404,11 @@ export function scheduleRetry(retry, options = {}) {
|
|
|
348
404
|
return scheduled
|
|
349
405
|
}
|
|
350
406
|
|
|
351
|
-
/** 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. */
|
|
352
410
|
export function scheduleRetrySoon(seconds = DEGRADED_RETRY_S, options = {}) {
|
|
353
|
-
return scheduleRetryIn(seconds, options)
|
|
411
|
+
return scheduleRetryIn(seconds, { ...options, args: [...(options.args ?? []), "--canary"] })
|
|
354
412
|
}
|
|
355
413
|
|
|
356
414
|
export function renderTimerUnits(input = {}) {
|
|
@@ -441,6 +499,8 @@ function usage() {
|
|
|
441
499
|
systemctl --user start lacode-refresh-pool.service
|
|
442
500
|
--print print the proposed pool.txt and make no changes (alias: --dry-run)
|
|
443
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)
|
|
444
504
|
--max N models per provider passed to the scorer (default: 50)
|
|
445
505
|
--scores PATH scores file (default: ${DEFAULT_SCORES_PATH})
|
|
446
506
|
--pool PATH pool file to read/compare/write (default: ${DEFAULT_POOL_PATH})
|
|
@@ -448,15 +508,16 @@ function usage() {
|
|
|
448
508
|
keep the previous pool (human override for a changed catalogue)
|
|
449
509
|
-h, --help this message
|
|
450
510
|
|
|
451
|
-
Degradation behaviour: before scanning, the refresh
|
|
452
|
-
https://status.opencode.ai/api/status.
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
slots) is never written; the
|
|
458
|
-
|
|
459
|
-
|
|
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
|
|
520
|
+
within 5 minutes while anything failed, even when the stated reset is hours out.`)
|
|
460
521
|
}
|
|
461
522
|
|
|
462
523
|
async function main() {
|
|
@@ -488,37 +549,36 @@ async function main() {
|
|
|
488
549
|
}
|
|
489
550
|
|
|
490
551
|
const currentPoolText = readTextFile(poolPath)
|
|
552
|
+
const canary = argv.includes("--canary")
|
|
491
553
|
|
|
492
|
-
// Live
|
|
493
|
-
//
|
|
494
|
-
//
|
|
495
|
-
//
|
|
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
|
|
496
558
|
// protects the pool if the scan comes back degenerate.
|
|
497
|
-
let
|
|
498
|
-
if (!printOnly) {
|
|
559
|
+
let deprioritized = []
|
|
560
|
+
if (!printOnly && !fromScores) {
|
|
499
561
|
try {
|
|
500
|
-
|
|
562
|
+
deprioritized = statusDeprioritizedProviders(await fetchProviderStatus())
|
|
501
563
|
} catch (error) {
|
|
502
|
-
|
|
564
|
+
console.log(`live status unreadable (${String(error?.message ?? error)}); scanning in default order`)
|
|
503
565
|
}
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
console.log(`${skip.reason}; keeping the current pool of ${poolSlotCount(currentPoolText)} slots`)
|
|
507
|
-
if (!noRetry) {
|
|
508
|
-
const scheduled = scheduleRetrySoon()
|
|
509
|
-
console.log(
|
|
510
|
-
scheduled.scheduled
|
|
511
|
-
? `scheduled degraded recheck in ${scheduled.seconds}s`
|
|
512
|
-
: `degraded recheck not scheduled: ${scheduled.reason}`,
|
|
513
|
-
)
|
|
514
|
-
}
|
|
515
|
-
return
|
|
566
|
+
if (deprioritized.length > 0) {
|
|
567
|
+
console.log(`status hint: probing ${deprioritized.join(", ")} last`)
|
|
516
568
|
}
|
|
517
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)
|
|
518
574
|
|
|
519
575
|
if (!fromScores) {
|
|
520
576
|
try {
|
|
521
|
-
|
|
577
|
+
if (canary) {
|
|
578
|
+
runCanary({ poolPath, currentPoolText, scorerArgs, max })
|
|
579
|
+
} else {
|
|
580
|
+
runFullScan()
|
|
581
|
+
}
|
|
522
582
|
} catch (error) {
|
|
523
583
|
console.error(`scorer failed; pool.txt not written: ${String(error?.message ?? error)}`)
|
|
524
584
|
if (!printOnly && !noRetry) {
|
|
@@ -562,6 +622,21 @@ async function main() {
|
|
|
562
622
|
|
|
563
623
|
const oldSlotCount = poolSlotCount(currentPoolText)
|
|
564
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
|
+
}
|
|
565
640
|
const dropped = scores.providers.flatMap((entry) => (Array.isArray(entry?.excluded) ? entry.excluded : []))
|
|
566
641
|
const summary = [
|
|
567
642
|
`selected [${built.chosen.selected.join(", ")}]`,
|
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,6 +101,13 @@ 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})
|
|
@@ -126,6 +134,16 @@ 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)
|
|
@@ -133,6 +151,7 @@ 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
|
})
|