@lawaty/lacode 2.3.0-beta.1 → 2.3.0-beta.3
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 +31 -0
- package/README.md +12 -4
- package/dist/index.js +70 -24
- package/dist/tui.js +114 -13
- package/docs/CONFIGURATION.md +10 -2
- package/docs/USER-STORIES.md +20 -5
- package/package.json +6 -3
- package/scripts/refresh-pool.mjs +141 -15
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,33 @@ commit as the changelog entry it describes.
|
|
|
9
9
|
|
|
10
10
|
## [Unreleased]
|
|
11
11
|
|
|
12
|
+
## [2.3.0-beta.3] — persist autonomy modes in global config
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- Modes: YOLO/YKB modes now persist in the lacode plugin options inside the global `opencode.jsonc` (single config file, comment-preserving edits).
|
|
17
|
+
|
|
18
|
+
### Removed
|
|
19
|
+
|
|
20
|
+
- Modes: removed `~/.config/lacode/modes.json` and `LACODE_MODES_FILE` seeding.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- Build: externalized the `jsonc-parser` runtime dependency.
|
|
25
|
+
|
|
26
|
+
## [2.3.0-beta.2] — degenerate refreshes never shrink the pool
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
|
|
30
|
+
- Pool: the refresh checks the live opencode status (`https://status.opencode.ai/api/status`) before scanning. When opencode's serving path (`inference`, `go`, `free`, `payg`) reports degraded and the current pool is intact, the scan is skipped, the pool is kept untouched, and a recheck is scheduled in 5 minutes. The signal is cached for 5 minutes and an unreachable status endpoint never blocks a refresh.
|
|
31
|
+
- Pool: the refresh writer now refuses any selection below half the previous pool (minimum four slots). A degenerate scan keeps the previous `pool.txt`, logs the refusal, and schedules a 5-minute recheck. A thin or missing pool is always allowed to recover.
|
|
32
|
+
- Pool: after any failed run, the recheck happens within 5 minutes even when the stated reset is hours out; a sooner provider reset is still honoured.
|
|
33
|
+
|
|
34
|
+
### Changed
|
|
35
|
+
|
|
36
|
+
- Pool: the transient retry cadence while degraded is 5 minutes instead of waiting out the full stated reset, so recovery is detected on the next recheck rather than the next 6-hour slot.
|
|
37
|
+
- `refresh-pool --install-timer --stable` now also copies `scripts/provider-status.mjs` to the stable lib dir.
|
|
38
|
+
|
|
12
39
|
## [2.3.0-beta.1] — provider degradation backoff
|
|
13
40
|
|
|
14
41
|
### Added
|
|
@@ -1213,6 +1240,10 @@ experimental; `latest` remains 0.4.2.
|
|
|
1213
1240
|
[Unreleased]: https://github.com/lawaty/lacode/compare/v2.1.0-beta.0...HEAD
|
|
1214
1241
|
[2.1.0-beta.0]: https://github.com/lawaty/lacode/compare/v2.0.0-beta.0...v2.1.0-beta.0
|
|
1215
1242
|
[2.0.0-beta.0]: https://github.com/lawaty/lacode/compare/v1.3.1...v2.0.0-beta.0
|
|
1243
|
+
[2.3.0-beta.3]: https://github.com/lawaty/lacode/compare/v2.3.0-beta.2...v2.3.0-beta.3
|
|
1244
|
+
[2.3.0-beta.2]: https://github.com/lawaty/lacode/compare/v2.3.0-beta.1...v2.3.0-beta.2
|
|
1245
|
+
[2.3.0-beta.1]: https://github.com/lawaty/lacode/compare/v2.3.0-beta.0...v2.3.0-beta.1
|
|
1246
|
+
[2.3.0-beta.0]: https://github.com/lawaty/lacode/releases/tag/v2.3.0-beta.0
|
|
1216
1247
|
[1.3.1]: https://github.com/lawaty/lacode/releases/tag/v1.3.1
|
|
1217
1248
|
[1.3.0-beta.9]: https://github.com/lawaty/lacode/releases/tag/v1.3.0-beta.9
|
|
1218
1249
|
[1.3.0-beta.8]: https://github.com/lawaty/lacode/releases/tag/v1.3.0-beta.8
|
package/README.md
CHANGED
|
@@ -156,15 +156,15 @@ option is optional:
|
|
|
156
156
|
| `feedbackHint` | `true` | Show the one-time feedback note on the first interactive run. `false` — or `LACODE_NO_FEEDBACK_HINT=1` — turns it off. The flag lives in `~/.local/share/opencode/lacode/feedback.json`; nothing is sent anywhere. |
|
|
157
157
|
| `sound` | `true` | Play a short tone with each notification, one per outcome, so you can tell a finished session from a question or a permission prompt without reading the popup. It is opt-out: `false` — or `LACODE_SOUND=0` (`false`, `off`, `no` work too, and beat the option) — turns the sound off, and anything else, a typo included, leaves it on. |
|
|
158
158
|
| `omniroute` | — | Optional [OmniRoute](docs/OMNIROUTE.md) gateway integration. Absent or `{ enabled: false }` changes nothing. |
|
|
159
|
-
| `yolo` | `false` |
|
|
160
|
-
| `ykb` | `false` |
|
|
159
|
+
| `yolo` | `false` | YOLO mode: permission requests that would ask are auto-approved. Explicit `deny` rules still win. The TUI writes this option when you run `/yolo`. |
|
|
160
|
+
| `ykb` | `false` | YKB (You Know Better) mode: the agent is told not to ask, and questions that still appear are answered with the recommended option. The TUI writes this option when you run `/ykb`. |
|
|
161
161
|
| `watchMs` | `1000` | How often the pool files are checked for changes. `0` disables live reload. |
|
|
162
162
|
| `watchDebounceMs` | `250` | Collapse a burst of editor writes into one reload. |
|
|
163
163
|
| `imagePaste` | — | Image attachments: `maxBytes` (4 MiB), `maxImages` (10) and `dir`. |
|
|
164
164
|
|
|
165
165
|
## Modes
|
|
166
166
|
|
|
167
|
-
Two runtime modes
|
|
167
|
+
Two runtime modes live on the lacode plugin entry and can be toggled from the TUI:
|
|
168
168
|
|
|
169
169
|
| Command | Effect |
|
|
170
170
|
| --- | --- |
|
|
@@ -172,7 +172,15 @@ Two runtime modes can be seeded from plugin options and toggled from the TUI:
|
|
|
172
172
|
| `/ykb` | Toggle YKB (You Know Better). The agent is told not to ask questions and to continue with its recommendation. A question that still appears is answered with its `(Recommended)` option, or its first option when none is marked. |
|
|
173
173
|
| `/modes` | Show both switches. |
|
|
174
174
|
|
|
175
|
-
|
|
175
|
+
```jsonc
|
|
176
|
+
{
|
|
177
|
+
"plugin": [
|
|
178
|
+
["@lawaty/lacode", { "yolo": true, "ykb": false }]
|
|
179
|
+
]
|
|
180
|
+
}
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
The toggles edit the `yolo` / `ykb` options on the lacode plugin entry in your global opencode config (`~/.config/opencode/opencode.jsonc`, or `$XDG_CONFIG_HOME/opencode/opencode.jsonc`), preserving comments. The server reads that file on the next decision — no restart. Turning a mode on also answers requests that are already pending. The lacode plugin entry must already exist in the global config; a project-only entry cannot be toggled. A free-text question with no options is left for the user, since there is no option to choose.
|
|
176
184
|
|
|
177
185
|
### Staying up to date
|
|
178
186
|
|
package/dist/index.js
CHANGED
|
@@ -2282,9 +2282,9 @@ var STATE_DIR = join3(homedir2(), ".local", "share", "opencode", "lacode");
|
|
|
2282
2282
|
var STATE_PATH = join3(STATE_DIR, "selfupdate.json");
|
|
2283
2283
|
var LOCK_PATH = join3(STATE_DIR, "selfupdate.lock");
|
|
2284
2284
|
function compareSemver(a, b) {
|
|
2285
|
-
const
|
|
2286
|
-
const left =
|
|
2287
|
-
const right =
|
|
2285
|
+
const parse2 = (v) => v.split("-")[0].split("+")[0].split(".").map((n) => Number.parseInt(n, 10) || 0);
|
|
2286
|
+
const left = parse2(a);
|
|
2287
|
+
const right = parse2(b);
|
|
2288
2288
|
for (let i = 0; i < 3; i++) {
|
|
2289
2289
|
const l = left[i] ?? 0;
|
|
2290
2290
|
const r = right[i] ?? 0;
|
|
@@ -6167,35 +6167,82 @@ async function imagePasteHooks(input, options, deps) {
|
|
|
6167
6167
|
}
|
|
6168
6168
|
}
|
|
6169
6169
|
|
|
6170
|
-
// src/lib/
|
|
6170
|
+
// src/lib/opencode-config.ts
|
|
6171
6171
|
import { existsSync as existsSync5, mkdirSync as mkdirSync9, readFileSync as readFileSync10, renameSync as renameSync7, writeFileSync as writeFileSync10 } from "node:fs";
|
|
6172
6172
|
import { homedir as homedir8 } from "node:os";
|
|
6173
6173
|
import { dirname as dirname6, join as join12 } from "node:path";
|
|
6174
|
-
|
|
6174
|
+
import { applyEdits, modify, parse, printParseErrorCode } from "jsonc-parser";
|
|
6175
|
+
function globalOpencodeConfigPath() {
|
|
6176
|
+
const base = process.env.XDG_CONFIG_HOME ?? join12(homedir8(), ".config");
|
|
6177
|
+
const jsonc = join12(base, "opencode", "opencode.jsonc");
|
|
6178
|
+
return existsSync5(jsonc) ? jsonc : join12(base, "opencode", "opencode.json");
|
|
6179
|
+
}
|
|
6180
|
+
var OpenCodeConfigError = class extends Error {
|
|
6181
|
+
constructor(message) {
|
|
6182
|
+
super(message);
|
|
6183
|
+
this.name = "OpenCodeConfigError";
|
|
6184
|
+
}
|
|
6185
|
+
};
|
|
6186
|
+
function isPlainObject(value) {
|
|
6187
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
6188
|
+
}
|
|
6189
|
+
function readOpencodeConfig(path4 = globalOpencodeConfigPath()) {
|
|
6190
|
+
if (!existsSync5(path4)) return {};
|
|
6191
|
+
const text = readFileSync10(path4, "utf8");
|
|
6192
|
+
const errors = [];
|
|
6193
|
+
const parsed = parse(text, errors, { allowTrailingComma: true, disallowComments: false });
|
|
6194
|
+
if (errors.length > 0) {
|
|
6195
|
+
const detail = errors.map((e) => `${printParseErrorCode(e.error)} at offset ${e.offset}`).join(", ");
|
|
6196
|
+
throw new OpenCodeConfigError(`Failed to parse ${path4}: ${detail}`);
|
|
6197
|
+
}
|
|
6198
|
+
if (!isPlainObject(parsed)) {
|
|
6199
|
+
throw new OpenCodeConfigError(`Config root must be an object: ${path4}`);
|
|
6200
|
+
}
|
|
6201
|
+
return parsed;
|
|
6202
|
+
}
|
|
6203
|
+
function pluginName(entry) {
|
|
6204
|
+
if (typeof entry === "string") return entry;
|
|
6205
|
+
if (Array.isArray(entry) && typeof entry[0] === "string") return entry[0];
|
|
6206
|
+
return void 0;
|
|
6207
|
+
}
|
|
6208
|
+
function matchesLacode(name) {
|
|
6209
|
+
if (name === "@lawaty/lacode") return true;
|
|
6210
|
+
const segments = name.split(/[\\/]/);
|
|
6211
|
+
return segments.some((s) => s.toLowerCase() === "lacode");
|
|
6212
|
+
}
|
|
6213
|
+
function findLacodePlugin(config) {
|
|
6214
|
+
const plugins = config.plugin;
|
|
6215
|
+
if (!Array.isArray(plugins)) return void 0;
|
|
6216
|
+
for (let index = 0; index < plugins.length; index++) {
|
|
6217
|
+
const entry = plugins[index];
|
|
6218
|
+
const name = pluginName(entry);
|
|
6219
|
+
if (!name || !matchesLacode(name)) continue;
|
|
6220
|
+
const options = Array.isArray(entry) && isPlainObject(entry[1]) ? entry[1] : void 0;
|
|
6221
|
+
return { index, name, options };
|
|
6222
|
+
}
|
|
6223
|
+
return void 0;
|
|
6224
|
+
}
|
|
6225
|
+
function readModeOptions(config = readOpencodeConfig()) {
|
|
6226
|
+
const entry = findLacodePlugin(config);
|
|
6227
|
+
const options = entry?.options;
|
|
6228
|
+
const yolo = typeof options?.yolo === "boolean" ? options.yolo : void 0;
|
|
6229
|
+
const ykb = typeof options?.ykb === "boolean" ? options.ykb : void 0;
|
|
6230
|
+
return { yolo, ykb };
|
|
6231
|
+
}
|
|
6232
|
+
|
|
6233
|
+
// src/lib/modes.ts
|
|
6175
6234
|
var YKB_INSTRUCTION = "YKB MODE (You Know Better) is ON. Do not ask the user questions, request clarification, or wait for confirmation. When a decision is needed, choose the option you would recommend, state the choice briefly, and continue until the task is complete. Stop only if you are genuinely blocked by a missing credential, an unrecoverable error, or a permission that cannot be granted.";
|
|
6176
|
-
var bool = (value, fallback) => typeof value === "boolean" ? value : fallback;
|
|
6177
6235
|
function readModes(defaults = {}) {
|
|
6178
|
-
const fallback = { yolo: defaults.yolo === true, ykb: defaults.ykb === true };
|
|
6179
6236
|
try {
|
|
6180
|
-
const
|
|
6181
|
-
return {
|
|
6237
|
+
const options = readModeOptions();
|
|
6238
|
+
return {
|
|
6239
|
+
yolo: options.yolo ?? defaults.yolo === true,
|
|
6240
|
+
ykb: options.ykb ?? defaults.ykb === true
|
|
6241
|
+
};
|
|
6182
6242
|
} catch {
|
|
6183
|
-
return
|
|
6243
|
+
return { yolo: defaults.yolo === true, ykb: defaults.ykb === true };
|
|
6184
6244
|
}
|
|
6185
6245
|
}
|
|
6186
|
-
function writeModes(modes) {
|
|
6187
|
-
const path4 = MODES_FILE;
|
|
6188
|
-
mkdirSync9(dirname6(path4), { recursive: true });
|
|
6189
|
-
const tmp = `${path4}.tmp`;
|
|
6190
|
-
writeFileSync10(tmp, `${JSON.stringify({ yolo: modes.yolo, ykb: modes.ykb }, null, 2)}
|
|
6191
|
-
`);
|
|
6192
|
-
renameSync7(tmp, path4);
|
|
6193
|
-
}
|
|
6194
|
-
function seedModes(defaults) {
|
|
6195
|
-
if (existsSync5(MODES_FILE)) return;
|
|
6196
|
-
if (defaults.yolo !== true && defaults.ykb !== true) return;
|
|
6197
|
-
writeModes({ yolo: defaults.yolo === true, ykb: defaults.ykb === true });
|
|
6198
|
-
}
|
|
6199
6246
|
function recommendedAnswer(question) {
|
|
6200
6247
|
const q = question;
|
|
6201
6248
|
const options = (Array.isArray(q?.options) ? q.options : []).filter(
|
|
@@ -6214,7 +6261,6 @@ async function modesHooks(input, options) {
|
|
|
6214
6261
|
const defaults = {};
|
|
6215
6262
|
if (typeof opts.yolo === "boolean") defaults.yolo = opts.yolo;
|
|
6216
6263
|
if (typeof opts.ykb === "boolean") defaults.ykb = opts.ykb;
|
|
6217
|
-
seedModes(defaults);
|
|
6218
6264
|
const state2 = () => readModes(defaults);
|
|
6219
6265
|
const warn2 = (message) => {
|
|
6220
6266
|
input.client.app.log({ body: { service: "modes", level: "warn", message } }).catch(() => {
|
package/dist/tui.js
CHANGED
|
@@ -943,28 +943,129 @@ var plugin = {
|
|
|
943
943
|
};
|
|
944
944
|
var timeline_default = plugin;
|
|
945
945
|
|
|
946
|
-
// src/lib/
|
|
946
|
+
// src/lib/opencode-config.ts
|
|
947
947
|
import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync3, renameSync, writeFileSync } from "node:fs";
|
|
948
948
|
import { homedir as homedir3 } from "node:os";
|
|
949
949
|
import { dirname, join as join4 } from "node:path";
|
|
950
|
-
|
|
951
|
-
|
|
950
|
+
import { applyEdits, modify, parse, printParseErrorCode } from "jsonc-parser";
|
|
951
|
+
function globalOpencodeConfigPath() {
|
|
952
|
+
const base = process.env.XDG_CONFIG_HOME ?? join4(homedir3(), ".config");
|
|
953
|
+
const jsonc = join4(base, "opencode", "opencode.jsonc");
|
|
954
|
+
return existsSync2(jsonc) ? jsonc : join4(base, "opencode", "opencode.json");
|
|
955
|
+
}
|
|
956
|
+
var OpenCodeConfigError = class extends Error {
|
|
957
|
+
constructor(message) {
|
|
958
|
+
super(message);
|
|
959
|
+
this.name = "OpenCodeConfigError";
|
|
960
|
+
}
|
|
961
|
+
};
|
|
962
|
+
function isPlainObject(value) {
|
|
963
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
964
|
+
}
|
|
965
|
+
function readOpencodeConfig(path = globalOpencodeConfigPath()) {
|
|
966
|
+
if (!existsSync2(path)) return {};
|
|
967
|
+
const text = readFileSync3(path, "utf8");
|
|
968
|
+
const errors = [];
|
|
969
|
+
const parsed = parse(text, errors, { allowTrailingComma: true, disallowComments: false });
|
|
970
|
+
if (errors.length > 0) {
|
|
971
|
+
const detail = errors.map((e) => `${printParseErrorCode(e.error)} at offset ${e.offset}`).join(", ");
|
|
972
|
+
throw new OpenCodeConfigError(`Failed to parse ${path}: ${detail}`);
|
|
973
|
+
}
|
|
974
|
+
if (!isPlainObject(parsed)) {
|
|
975
|
+
throw new OpenCodeConfigError(`Config root must be an object: ${path}`);
|
|
976
|
+
}
|
|
977
|
+
return parsed;
|
|
978
|
+
}
|
|
979
|
+
function pluginName(entry) {
|
|
980
|
+
if (typeof entry === "string") return entry;
|
|
981
|
+
if (Array.isArray(entry) && typeof entry[0] === "string") return entry[0];
|
|
982
|
+
return void 0;
|
|
983
|
+
}
|
|
984
|
+
function matchesLacode(name) {
|
|
985
|
+
if (name === "@lawaty/lacode") return true;
|
|
986
|
+
const segments = name.split(/[\\/]/);
|
|
987
|
+
return segments.some((s) => s.toLowerCase() === "lacode");
|
|
988
|
+
}
|
|
989
|
+
function findLacodePlugin(config) {
|
|
990
|
+
const plugins = config.plugin;
|
|
991
|
+
if (!Array.isArray(plugins)) return void 0;
|
|
992
|
+
for (let index = 0; index < plugins.length; index++) {
|
|
993
|
+
const entry = plugins[index];
|
|
994
|
+
const name = pluginName(entry);
|
|
995
|
+
if (!name || !matchesLacode(name)) continue;
|
|
996
|
+
const options = Array.isArray(entry) && isPlainObject(entry[1]) ? entry[1] : void 0;
|
|
997
|
+
return { index, name, options };
|
|
998
|
+
}
|
|
999
|
+
return void 0;
|
|
1000
|
+
}
|
|
1001
|
+
function readModeOptions(config = readOpencodeConfig()) {
|
|
1002
|
+
const entry = findLacodePlugin(config);
|
|
1003
|
+
const options = entry?.options;
|
|
1004
|
+
const yolo = typeof options?.yolo === "boolean" ? options.yolo : void 0;
|
|
1005
|
+
const ykb = typeof options?.ykb === "boolean" ? options.ykb : void 0;
|
|
1006
|
+
return { yolo, ykb };
|
|
1007
|
+
}
|
|
1008
|
+
function writeModeOptions(patch, path = globalOpencodeConfigPath()) {
|
|
1009
|
+
if (!existsSync2(path)) {
|
|
1010
|
+
throw new OpenCodeConfigError(`Cannot write mode options: config does not exist: ${path}`);
|
|
1011
|
+
}
|
|
1012
|
+
let text = readFileSync3(path, "utf8");
|
|
1013
|
+
const errors = [];
|
|
1014
|
+
const parsed = parse(text, errors, { allowTrailingComma: true, disallowComments: false });
|
|
1015
|
+
if (errors.length > 0) {
|
|
1016
|
+
const detail = errors.map((e) => `${printParseErrorCode(e.error)} at offset ${e.offset}`).join(", ");
|
|
1017
|
+
throw new OpenCodeConfigError(`Failed to parse ${path}: ${detail}`);
|
|
1018
|
+
}
|
|
1019
|
+
if (!isPlainObject(parsed)) {
|
|
1020
|
+
throw new OpenCodeConfigError(`Config root must be an object: ${path}`);
|
|
1021
|
+
}
|
|
1022
|
+
const entry = findLacodePlugin(parsed);
|
|
1023
|
+
if (!entry) {
|
|
1024
|
+
throw new OpenCodeConfigError(`lacode plugin entry not found in ${path}`);
|
|
1025
|
+
}
|
|
1026
|
+
const formattingOptions = { insertSpaces: true, tabSize: 2, eol: "\n" };
|
|
1027
|
+
const pluginEntry = parsed.plugin[entry.index];
|
|
1028
|
+
if (typeof pluginEntry === "string") {
|
|
1029
|
+
const obj = {};
|
|
1030
|
+
if (patch.yolo !== void 0) obj.yolo = patch.yolo;
|
|
1031
|
+
if (patch.ykb !== void 0) obj.ykb = patch.ykb;
|
|
1032
|
+
const edits = modify(text, ["plugin", entry.index], [entry.name, obj], { formattingOptions });
|
|
1033
|
+
text = applyEdits(text, edits);
|
|
1034
|
+
} else if (Array.isArray(pluginEntry) && !isPlainObject(pluginEntry[1])) {
|
|
1035
|
+
const obj = {};
|
|
1036
|
+
if (patch.yolo !== void 0) obj.yolo = patch.yolo;
|
|
1037
|
+
if (patch.ykb !== void 0) obj.ykb = patch.ykb;
|
|
1038
|
+
const edits = modify(text, ["plugin", entry.index, 1], obj, { formattingOptions });
|
|
1039
|
+
text = applyEdits(text, edits);
|
|
1040
|
+
} else {
|
|
1041
|
+
const keys = ["yolo", "ykb"];
|
|
1042
|
+
for (const key of keys) {
|
|
1043
|
+
const value = patch[key];
|
|
1044
|
+
if (value === void 0) continue;
|
|
1045
|
+
const edits = modify(text, ["plugin", entry.index, 1, key], value, { formattingOptions });
|
|
1046
|
+
text = applyEdits(text, edits);
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
1050
|
+
const tmp = `${path}.tmp`;
|
|
1051
|
+
writeFileSync(tmp, text);
|
|
1052
|
+
renameSync(tmp, path);
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
// src/lib/modes.ts
|
|
952
1056
|
function readModes(defaults = {}) {
|
|
953
|
-
const fallback = { yolo: defaults.yolo === true, ykb: defaults.ykb === true };
|
|
954
1057
|
try {
|
|
955
|
-
const
|
|
956
|
-
return {
|
|
1058
|
+
const options = readModeOptions();
|
|
1059
|
+
return {
|
|
1060
|
+
yolo: options.yolo ?? defaults.yolo === true,
|
|
1061
|
+
ykb: options.ykb ?? defaults.ykb === true
|
|
1062
|
+
};
|
|
957
1063
|
} catch {
|
|
958
|
-
return
|
|
1064
|
+
return { yolo: defaults.yolo === true, ykb: defaults.ykb === true };
|
|
959
1065
|
}
|
|
960
1066
|
}
|
|
961
1067
|
function writeModes(modes) {
|
|
962
|
-
|
|
963
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
964
|
-
const tmp = `${path}.tmp`;
|
|
965
|
-
writeFileSync(tmp, `${JSON.stringify({ yolo: modes.yolo, ykb: modes.ykb }, null, 2)}
|
|
966
|
-
`);
|
|
967
|
-
renameSync(tmp, path);
|
|
1068
|
+
writeModeOptions({ yolo: modes.yolo, ykb: modes.ykb });
|
|
968
1069
|
}
|
|
969
1070
|
function toggleMode(name, defaults = {}) {
|
|
970
1071
|
const current = readModes(defaults);
|
package/docs/CONFIGURATION.md
CHANGED
|
@@ -52,7 +52,7 @@ restart.
|
|
|
52
52
|
|
|
53
53
|
## Modes
|
|
54
54
|
|
|
55
|
-
Two runtime modes
|
|
55
|
+
Two runtime modes live on the lacode plugin entry and can be toggled from the TUI:
|
|
56
56
|
|
|
57
57
|
| Command | Effect |
|
|
58
58
|
| --- | --- |
|
|
@@ -60,7 +60,15 @@ Two runtime modes can be seeded from plugin options and toggled from the TUI:
|
|
|
60
60
|
| `/ykb` | Toggle YKB (You Know Better). The agent is told not to ask questions and to continue with its recommendation. A question that still appears is answered with its `(Recommended)` option, or its first option when none is marked. |
|
|
61
61
|
| `/modes` | Show both switches. |
|
|
62
62
|
|
|
63
|
-
|
|
63
|
+
```jsonc
|
|
64
|
+
{
|
|
65
|
+
"plugin": [
|
|
66
|
+
["@lawaty/lacode", { "yolo": true, "ykb": false }]
|
|
67
|
+
]
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The toggles edit the `yolo` / `ykb` options on the lacode plugin entry in your global opencode config (`~/.config/opencode/opencode.jsonc`, or `$XDG_CONFIG_HOME/opencode/opencode.jsonc`), preserving comments. The server reads that file on the next decision — no restart. Turning a mode on also answers requests that are already pending. The lacode plugin entry must already exist in the global config; a project-only entry cannot be toggled. A free-text question with no options is left for the user, since there is no option to choose.
|
|
64
72
|
|
|
65
73
|
|
|
66
74
|
## Which models run which agent
|
package/docs/USER-STORIES.md
CHANGED
|
@@ -180,12 +180,26 @@ A daily scan was killed by a reboot mid-run, and because systemd records a trigg
|
|
|
180
180
|
**Acceptance criteria:**
|
|
181
181
|
- [ ] Given the installed timer, When it is rendered, Then it has `OnCalendar=*-*-* 00/6:00:00`, `OnStartupSec=5min`, and `Persistent=true`, and the service has `TimeoutStartSec=infinity`.
|
|
182
182
|
- [ ] Given a scan reaches its wall-clock budget, When the scores are written, Then unscanned and truncated providers keep their previous rows, so a partial run cannot shrink the pool.
|
|
183
|
-
- [ ] Given a model failed transiently (timeout, rate limit, credit), When selection runs, Then its last measured score is retained and a retry is scheduled
|
|
183
|
+
- [ ] Given a model failed transiently (timeout, rate limit, credit), When selection runs, Then its last measured score is retained and a retry is scheduled; the refresh rechecks within 5 minutes while anything failed, even when the stated reset is hours out.
|
|
184
184
|
- [ ] Given a permanent failure (401, 403 agreement, 404), When selection runs, Then the model is not retried and does not fall back to a previous score.
|
|
185
185
|
- [ ] Given three consecutive provider-level timeouts or network errors during a scan, When the breaker trips, Then the scan stops probing that provider, keeps its previous rows, and names the provider as degraded.
|
|
186
186
|
- [ ] Given enough other providers to fill the pool, When selection runs, Then exactly one `opencode` slot is reserved last with weight 2.
|
|
187
187
|
- [ ] Given two refreshes overlap, When the second starts, Then it skips while the lock is held, and a lock left by a dead PID is reclaimed.
|
|
188
188
|
|
|
189
|
+
### US-40: A degraded scan never shrinks the pool, and recovery is detected within minutes
|
|
190
|
+
**As a** user whose providers degrade for minutes to hours, **I want** the refresh to keep the working pool through the outage and restore it as soon as the degradation lifts, **so that** a transient outage never strands me on two slots and I never wait a full cycle for the recovery.
|
|
191
|
+
|
|
192
|
+
A refresh once replaced a healthy ten-slot pool with a two-slot scan because nothing stopped a degenerate selection from being written. The writer now refuses any selection below half the previous pool (minimum four slots) and keeps the previous file, and before scanning it checks the live opencode status: when opencode's serving path reports degraded and the pool is intact, the scan is skipped entirely and a recheck is scheduled in 5 minutes. The same 5-minute cadence applies after any failed run, so the moment the status clears — or a scan comes back full — the pool is restored on the next recheck instead of the next 6-hour slot.
|
|
193
|
+
|
|
194
|
+
**Story ID(s) for tests:** US-40
|
|
195
|
+
**Acceptance criteria:**
|
|
196
|
+
- [ ] Given a healthy pool and a scan that returns fewer slots than half of it (minimum four), When the refresh finishes, Then the previous pool file is left untouched, the refusal is logged, and a recheck is scheduled in 5 minutes.
|
|
197
|
+
- [ ] Given opencode's serving path reports degraded on the live status and the current pool is intact, When the refresh runs, Then no scan is started, the pool is kept, and a recheck is scheduled in 5 minutes.
|
|
198
|
+
- [ ] Given any failed run with a stated reset hours out, When the retry is scheduled, Then the recheck still happens within 5 minutes; a sooner provider reset is honoured.
|
|
199
|
+
- [ ] Given the degradation lifts, When the next 5-minute recheck runs, Then a full scan restores the pool instead of waiting for the next 6-hour slot.
|
|
200
|
+
- [ ] Given a thin or missing pool, When a fuller scan completes, Then it is always written — the guard only ever blocks shrinking, never recovery.
|
|
201
|
+
- [ ] Given the status endpoint is unreachable, When the refresh runs, Then the scan proceeds as before, because an unknown signal must never block a refresh and the write guard still protects the pool.
|
|
202
|
+
|
|
189
203
|
---
|
|
190
204
|
|
|
191
205
|
## Epic C — Hang detection and auto-recovery
|
|
@@ -555,15 +569,15 @@ A failed summarization used to clear the pending flag and never reschedule, so o
|
|
|
555
569
|
### US-37: YOLO mode approves permissions without stopping
|
|
556
570
|
**As a** user running a long autonomous task, **I want** permission prompts to be approved automatically while YOLO is on, **so that** the agent keeps working instead of waiting for routine approvals, while an explicit `deny` still protects me.
|
|
557
571
|
|
|
558
|
-
Permission prompts are the main thing that breaks a long autonomous run: every `ask` blocks the session until a human answers. YOLO flips that decision to `allow` at the permission hook, keeps an explicit `deny` authoritative, and answers any request that still reaches the event bus. The state
|
|
572
|
+
Permission prompts are the main thing that breaks a long autonomous run: every `ask` blocks the session until a human answers. YOLO flips that decision to `allow` at the permission hook, keeps an explicit `deny` authoritative, and answers any request that still reaches the event bus. The state lives in the `yolo` option on the lacode plugin entry in the global `opencode.jsonc`; the TUI writes it and the server reads it on the next decision, so enabling it also clears what is already pending.
|
|
559
573
|
|
|
560
574
|
**Story ID(s) for tests:** US-37
|
|
561
575
|
**Acceptance criteria:**
|
|
562
576
|
- [ ] Given YOLO is on, When the runtime's permission decision is `ask`, Then it becomes `allow` without a prompt, while an existing `deny` or `allow` is left unchanged.
|
|
563
577
|
- [ ] Given YOLO is off, When the runtime's permission decision is `ask`, Then it stays `ask` and still reaches the user.
|
|
564
578
|
- [ ] Given a `permission.asked` event still reaches the event bus while YOLO is on and carries a request id and a session id, When the handler runs, Then the plugin replies `once` for that request; an event missing either id is ignored.
|
|
565
|
-
- [ ] Given YOLO is toggled in the TUI, When the
|
|
566
|
-
- [ ] Given
|
|
579
|
+
- [ ] Given YOLO is toggled in the TUI, When the global `opencode.jsonc` changes, Then the next permission decision sees the new value without a restart, and requests already pending are answered.
|
|
580
|
+
- [ ] Given the TUI toggles a mode, When it writes the global `opencode.jsonc`, Then only the `yolo` / `ykb` options on the lacode plugin entry change, comments and formatting are preserved, and the config remains the single source of truth.
|
|
567
581
|
- [ ] Given the README and configuration reference, Then the `yolo` option and the `/yolo` command are documented alongside `/modes`.
|
|
568
582
|
|
|
569
583
|
### US-38: YKB mode chooses the recommendation instead of asking
|
|
@@ -606,6 +620,7 @@ table was filled in.
|
|
|
606
620
|
| US-28 | B | `tests/stories/i-omniroute.test.mjs` |
|
|
607
621
|
| US-31 | B | `tests/stories/b-provider-resilience.test.mjs`, `tests/stories/c-hang-recovery.test.mjs` |
|
|
608
622
|
| US-39 | B | `tests/stories/l-pool-refresh.test.mjs` |
|
|
623
|
+
| US-40 | B | `tests/stories/l-pool-refresh.test.mjs`, `tests/refresh-pool-test.mjs`, `tests/provider-status-test.mjs` |
|
|
609
624
|
| US-8 | C | `tests/stories/c-hang-recovery.test.mjs`, `tests/pool-hang-test.mjs` |
|
|
610
625
|
| US-9 | C | `tests/stories/c-hang-recovery.test.mjs`, `tests/pool-hang-test.mjs`, `tests/pool-shared-test.mjs` |
|
|
611
626
|
| US-10 | C | `tests/stories/c-hang-recovery.test.mjs`, `tests/pool-hang-test.mjs`, `tests/pool-timer-test.mjs` |
|
|
@@ -639,6 +654,6 @@ The root-level suites under `tests/` are unit-style; the `tests/stories/` suites
|
|
|
639
654
|
the behavioural layer, written one epic per file with every test name carrying
|
|
640
655
|
its story id. Run everything with `npm test`.
|
|
641
656
|
|
|
642
|
-
|
|
657
|
+
40/40 stories covered by 376 test case(s); thinnest story has 1.
|
|
643
658
|
No story is untested. The scan covers the `tests/stories/` suites and the root-level
|
|
644
659
|
`tests/*-test.mjs` suites.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lawaty/lacode",
|
|
3
|
-
"version": "2.3.0-beta.
|
|
3
|
+
"version": "2.3.0-beta.3",
|
|
4
4
|
"description": "Ultimate opencode setup for Go users — cost-controlled pooled subagents, hang recovery, and notifications for opencode.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -68,8 +68,8 @@
|
|
|
68
68
|
},
|
|
69
69
|
"peerDependencies": {
|
|
70
70
|
"@opencode-ai/plugin": "1.18.34",
|
|
71
|
-
"solid
|
|
72
|
-
"
|
|
71
|
+
"@opentui/solid": "*",
|
|
72
|
+
"solid-js": "*"
|
|
73
73
|
},
|
|
74
74
|
"peerDependenciesMeta": {
|
|
75
75
|
"solid-js": {
|
|
@@ -85,5 +85,8 @@
|
|
|
85
85
|
},
|
|
86
86
|
"publishConfig": {
|
|
87
87
|
"tag": "latest"
|
|
88
|
+
},
|
|
89
|
+
"dependencies": {
|
|
90
|
+
"jsonc-parser": "^3.3.1"
|
|
88
91
|
}
|
|
89
92
|
}
|
package/scripts/refresh-pool.mjs
CHANGED
|
@@ -23,6 +23,7 @@ import { execFileSync } from "node:child_process"
|
|
|
23
23
|
import { homedir, userInfo } from "node:os"
|
|
24
24
|
import { dirname, join, resolve } from "node:path"
|
|
25
25
|
import { fileURLToPath } from "node:url"
|
|
26
|
+
import { fetchProviderStatus } from "./provider-status.mjs"
|
|
26
27
|
|
|
27
28
|
const HOME = homedir()
|
|
28
29
|
export const DEFAULT_SCORES_PATH = join(HOME, ".config", "lacode", "model-scores.json")
|
|
@@ -32,9 +33,21 @@ export const STABLE_LIB_DIR = (home = HOME) => join(home, ".local", "lib", "laco
|
|
|
32
33
|
export const STABLE_SCRIPT = (home = HOME) => join(STABLE_LIB_DIR(home), "refresh-pool.mjs")
|
|
33
34
|
export const STABLE_SCORER = (home = HOME) => join(STABLE_LIB_DIR(home), "score-models.mjs")
|
|
34
35
|
export const STABLE_SELECTION = (home = HOME) => join(STABLE_LIB_DIR(home), "pool-selection.mjs")
|
|
36
|
+
export const STABLE_STATUS = (home = HOME) => join(STABLE_LIB_DIR(home), "provider-status.mjs")
|
|
35
37
|
export const REFRESH_SERVICE_NAME = "lacode-refresh-pool.service"
|
|
36
38
|
export const REFRESH_TIMER_NAME = "lacode-refresh-pool.timer"
|
|
37
39
|
|
|
40
|
+
/**
|
|
41
|
+
* A refresh must never replace a working pool with a degenerate one. When the
|
|
42
|
+
* new selection falls below the keep threshold, the previous pool.txt is left
|
|
43
|
+
* untouched, the failure is logged loudly, and a fast retry is scheduled.
|
|
44
|
+
* The threshold is half the previous pool (rounded down), with a floor of
|
|
45
|
+
* MIN_POOL_SLOTS so a thin-but-real pool is still protected.
|
|
46
|
+
*/
|
|
47
|
+
export const MIN_POOL_SLOTS = 4
|
|
48
|
+
/** While degraded, recheck on this cadence instead of waiting out a long reset. */
|
|
49
|
+
export const DEGRADED_RETRY_S = 5 * 60
|
|
50
|
+
|
|
38
51
|
const REFRESH_LOCK_DIR = join(HOME, ".config", "lacode", "refresh.lock")
|
|
39
52
|
const REFRESH_LOCK_STALE_MS = 60 * 60 * 1000
|
|
40
53
|
|
|
@@ -189,18 +202,56 @@ function runScorer(max) {
|
|
|
189
202
|
execFileSync(process.execPath, args, { stdio: "inherit" })
|
|
190
203
|
}
|
|
191
204
|
|
|
192
|
-
|
|
205
|
+
/**
|
|
206
|
+
* Count the usable model slots in a pool.txt text (comments and blanks ignored).
|
|
207
|
+
*/
|
|
208
|
+
export function poolSlotCount(text) {
|
|
209
|
+
return splitHeaderAndSlots(text).slots.filter(isSlotLine).length
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Pure fail-closed guard. A refresh must never replace a working pool with a
|
|
214
|
+
* degenerate selection: when the new slot count falls below half the previous
|
|
215
|
+
* pool (floored at MIN_POOL_SLOTS), the previous pool is kept. There is no
|
|
216
|
+
* previous pool on a first-ever run, so there is nothing to protect and the
|
|
217
|
+
* guard passes.
|
|
218
|
+
*/
|
|
219
|
+
export function shouldKeepPool({ currentPoolText, newSlotCount }) {
|
|
220
|
+
const prev = poolSlotCount(currentPoolText ?? "")
|
|
221
|
+
if (prev <= 0) return { keep: false, prev, threshold: 0, reason: "no previous pool to protect" }
|
|
222
|
+
const threshold = Math.max(MIN_POOL_SLOTS, Math.floor(prev / 2))
|
|
223
|
+
if (newSlotCount >= threshold) return { keep: false, prev, threshold, reason: "" }
|
|
224
|
+
return {
|
|
225
|
+
keep: true,
|
|
226
|
+
prev,
|
|
227
|
+
threshold,
|
|
228
|
+
reason: `new selection has ${newSlotCount} usable slot(s), below the keep threshold of ${threshold} (previous pool had ${prev}); refusing to shrink the pool`,
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* How long until the next look while degraded. A provider reset in the future
|
|
234
|
+
* is honoured, but the wait never exceeds the degraded cadence: a reset hours
|
|
235
|
+
* out must not stop the 5-minute rechecks, and a reset that already passed
|
|
236
|
+
* means recheck on the cadence. No retry information at all also means the
|
|
237
|
+
* cadence.
|
|
238
|
+
*/
|
|
239
|
+
export function degradedRetryDelay(retry, now = Date.now()) {
|
|
193
240
|
const at = Date.parse(retry?.after ?? "")
|
|
194
|
-
if (!Number.isFinite(at)) return
|
|
195
|
-
const
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
241
|
+
if (!Number.isFinite(at)) return DEGRADED_RETRY_S
|
|
242
|
+
const wait = Math.ceil((at - now) / 1000)
|
|
243
|
+
if (wait <= 0) return DEGRADED_RETRY_S
|
|
244
|
+
return Math.min(wait, DEGRADED_RETRY_S)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function scheduleRetryIn(seconds, options = {}) {
|
|
248
|
+
if (!Number.isFinite(seconds) || seconds <= 0) return { scheduled: false, reason: "delay is not in the future" }
|
|
249
|
+
if (seconds > 24 * 60 * 60) return { scheduled: false, reason: "delay is more than a day away" }
|
|
200
250
|
const exec = options.exec ?? execFileSync
|
|
201
251
|
const scriptPath = options.scriptPath ?? fileURLToPath(import.meta.url)
|
|
202
252
|
const nodePath = options.nodePath ?? process.execPath
|
|
203
253
|
const home = options.home ?? HOME
|
|
254
|
+
const nowMs = (options.now ?? Date.now)()
|
|
204
255
|
const path = `${home}/.opencode/bin:/usr/local/bin:/usr/bin:/bin`
|
|
205
256
|
const unit = `lacode-refresh-pool-retry-${Math.floor(nowMs / 1000).toString(36)}`
|
|
206
257
|
try {
|
|
@@ -218,12 +269,29 @@ export function scheduleRetry(retry, options = {}) {
|
|
|
218
269
|
],
|
|
219
270
|
{ stdio: "ignore" },
|
|
220
271
|
)
|
|
221
|
-
return { scheduled: true, seconds
|
|
272
|
+
return { scheduled: true, seconds }
|
|
222
273
|
} catch (error) {
|
|
223
274
|
return { scheduled: false, reason: String(error?.message ?? error) }
|
|
224
275
|
}
|
|
225
276
|
}
|
|
226
277
|
|
|
278
|
+
export function scheduleRetry(retry, options = {}) {
|
|
279
|
+
const at = Date.parse(retry?.after ?? "")
|
|
280
|
+
if (!Number.isFinite(at)) return { scheduled: false, reason: "no reset time" }
|
|
281
|
+
const nowMs = (options.now ?? Date.now)()
|
|
282
|
+
const seconds = Math.ceil((at - nowMs) / 1000)
|
|
283
|
+
if (!Number.isFinite(seconds) || seconds <= 0) return { scheduled: false, reason: "reset is not in the future" }
|
|
284
|
+
if (seconds > 24 * 60 * 60) return { scheduled: false, reason: "reset is more than a day away" }
|
|
285
|
+
const scheduled = scheduleRetryIn(seconds, options)
|
|
286
|
+
if (scheduled.scheduled) return { ...scheduled, after: retry.after }
|
|
287
|
+
return scheduled
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Schedule a recheck on the degraded cadence (default 5 minutes). */
|
|
291
|
+
export function scheduleRetrySoon(seconds = DEGRADED_RETRY_S, options = {}) {
|
|
292
|
+
return scheduleRetryIn(seconds, options)
|
|
293
|
+
}
|
|
294
|
+
|
|
227
295
|
export function renderTimerUnits(input = {}) {
|
|
228
296
|
const nodePath = input.nodePath || process.execPath
|
|
229
297
|
const scriptPath = input.scriptPath || fileURLToPath(import.meta.url)
|
|
@@ -271,6 +339,7 @@ function installTimerUnits(stable = false) {
|
|
|
271
339
|
copyFileSync(live, scriptPath)
|
|
272
340
|
copyFileSync(resolve(dirname(live), "score-models.mjs"), STABLE_SCORER(HOME))
|
|
273
341
|
copyFileSync(resolve(dirname(live), "pool-selection.mjs"), STABLE_SELECTION(HOME))
|
|
342
|
+
copyFileSync(resolve(dirname(live), "provider-status.mjs"), STABLE_STATUS(HOME))
|
|
274
343
|
}
|
|
275
344
|
mkdirSync(dirname(units.servicePath), { recursive: true })
|
|
276
345
|
writeFileAtomic(units.servicePath, units.service)
|
|
@@ -314,7 +383,15 @@ function usage() {
|
|
|
314
383
|
--max N models per provider passed to the scorer (default: 50)
|
|
315
384
|
--scores PATH scores file (default: ${DEFAULT_SCORES_PATH})
|
|
316
385
|
--pool PATH pool file to read/compare/write (default: ${DEFAULT_POOL_PATH})
|
|
317
|
-
-h, --help this message
|
|
386
|
+
-h, --help this message
|
|
387
|
+
|
|
388
|
+
Degradation behaviour: before scanning, the refresh checks
|
|
389
|
+
https://status.opencode.ai/api/status. When opencode's serving path reports
|
|
390
|
+
degraded and the current pool is intact, the scan is skipped, the pool is kept,
|
|
391
|
+
and a recheck is scheduled in 5 minutes. After a scan, a selection below half
|
|
392
|
+
the previous pool (minimum ${MIN_POOL_SLOTS} slots) is never written; the previous pool
|
|
393
|
+
is kept and a 5-minute recheck is scheduled. A retry is always rechecked
|
|
394
|
+
within 5 minutes while anything failed, even when the stated reset is hours out.`)
|
|
318
395
|
}
|
|
319
396
|
|
|
320
397
|
async function main() {
|
|
@@ -344,6 +421,37 @@ async function main() {
|
|
|
344
421
|
process.once("exit", releaseRefreshLock)
|
|
345
422
|
}
|
|
346
423
|
|
|
424
|
+
const currentPoolText = readTextFile(poolPath)
|
|
425
|
+
|
|
426
|
+
// Live degradation signal. When opencode's own serving path reports degraded
|
|
427
|
+
// and the current pool is intact, probing now can only shrink it: keep the
|
|
428
|
+
// pool untouched and recheck in 5 minutes. A thin pool is still scanned, so
|
|
429
|
+
// a first-ever run (or a previously clobbered one) can recover, and the
|
|
430
|
+
// write guard below protects it if the scan comes back degenerate.
|
|
431
|
+
let providerStatus = { ok: false, providers: [], services: [] }
|
|
432
|
+
if (!printOnly) {
|
|
433
|
+
try {
|
|
434
|
+
providerStatus = await fetchProviderStatus()
|
|
435
|
+
} catch (error) {
|
|
436
|
+
providerStatus = { ok: false, providers: [], services: [], reason: String(error?.message ?? error) }
|
|
437
|
+
}
|
|
438
|
+
if (providerStatus.ok && providerStatus.providers.length > 0 && poolSlotCount(currentPoolText) >= MIN_POOL_SLOTS) {
|
|
439
|
+
const detail = providerStatus.services.map((entry) => `${entry.service}=${entry.status}`).join(", ")
|
|
440
|
+
console.log(
|
|
441
|
+
`opencode status degraded (${detail}); keeping the current pool of ${poolSlotCount(currentPoolText)} slots and rechecking in ${DEGRADED_RETRY_S / 60} minutes`,
|
|
442
|
+
)
|
|
443
|
+
if (!noRetry) {
|
|
444
|
+
const scheduled = scheduleRetrySoon()
|
|
445
|
+
console.log(
|
|
446
|
+
scheduled.scheduled
|
|
447
|
+
? `scheduled degraded recheck in ${scheduled.seconds}s`
|
|
448
|
+
: `degraded recheck not scheduled: ${scheduled.reason}`,
|
|
449
|
+
)
|
|
450
|
+
}
|
|
451
|
+
return
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
347
455
|
if (!fromScores) {
|
|
348
456
|
try {
|
|
349
457
|
runScorer(max)
|
|
@@ -363,7 +471,6 @@ async function main() {
|
|
|
363
471
|
return
|
|
364
472
|
}
|
|
365
473
|
|
|
366
|
-
const currentPoolText = readTextFile(poolPath)
|
|
367
474
|
let built
|
|
368
475
|
try {
|
|
369
476
|
built = buildPool({ scores, currentPoolText, now: new Date() })
|
|
@@ -373,7 +480,7 @@ async function main() {
|
|
|
373
480
|
return
|
|
374
481
|
}
|
|
375
482
|
|
|
376
|
-
const oldSlotCount =
|
|
483
|
+
const oldSlotCount = poolSlotCount(currentPoolText)
|
|
377
484
|
const newSlotCount = built.slots.filter(isSlotLine).length
|
|
378
485
|
const dropped = scores.providers.flatMap((entry) => (Array.isArray(entry?.excluded) ? entry.excluded : []))
|
|
379
486
|
const summary = [
|
|
@@ -382,11 +489,30 @@ async function main() {
|
|
|
382
489
|
...(built.warnings.length ? [`warnings [${built.warnings.join("; ")}]`] : []),
|
|
383
490
|
].join(" | ")
|
|
384
491
|
|
|
385
|
-
|
|
386
|
-
|
|
492
|
+
// Fail closed on a degenerate selection: keep the previous pool, say so
|
|
493
|
+
// loudly, and recheck in 5 minutes instead of waiting out a long reset.
|
|
494
|
+
if (!printOnly) {
|
|
495
|
+
const kept = shouldKeepPool({ currentPoolText, newSlotCount })
|
|
496
|
+
if (kept.keep) {
|
|
497
|
+
console.error(`refresh refused (fail closed): ${kept.reason}`)
|
|
498
|
+
if (!noRetry) {
|
|
499
|
+
const scheduled = scheduleRetrySoon()
|
|
500
|
+
console.log(
|
|
501
|
+
scheduled.scheduled
|
|
502
|
+
? `scheduled degraded recheck in ${scheduled.seconds}s`
|
|
503
|
+
: `degraded recheck not scheduled: ${scheduled.reason}`,
|
|
504
|
+
)
|
|
505
|
+
}
|
|
506
|
+
process.exitCode = 1
|
|
507
|
+
return
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
if (!printOnly && !noRetry && scores.retry) {
|
|
512
|
+
const scheduled = scheduleRetrySoon(degradedRetryDelay(scores.retry))
|
|
387
513
|
if (scheduled.scheduled) {
|
|
388
|
-
console.log(`scheduled retry in ${scheduled.seconds}s for ${scores.retry.models.length} model(s)
|
|
389
|
-
} else
|
|
514
|
+
console.log(`scheduled retry in ${scheduled.seconds}s for ${scores.retry.models.length} model(s) (reset ${scores.retry.after})`)
|
|
515
|
+
} else {
|
|
390
516
|
console.log(`retry not scheduled: ${scheduled.reason}`)
|
|
391
517
|
}
|
|
392
518
|
}
|