opencode-harness-kit 0.5.3 → 0.5.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 +207 -17
- package/bin/oc-harness.mjs +278 -45
- package/dist/config.d.ts +5 -0
- package/dist/denylist.d.ts +5 -1
- package/dist/guard.d.ts +17 -1
- package/dist/health.d.ts +69 -7
- package/dist/index.js +452 -68
- package/dist/profile.d.ts +32 -2
- package/dist/telemetry-store.d.ts +22 -0
- package/docs/EXIT-CODES.md +7 -0
- package/docs/KNOWN-LIMITS.md +18 -7
- package/eval/measure-prune.mjs +13 -4
- package/package.json +1 -1
- package/src/config.js +126 -3
- package/src/denylist.js +46 -2
- package/src/guard.ts +78 -7
- package/src/health.js +130 -5
- package/src/lane-usage.js +13 -6
- package/src/profile.js +187 -25
- package/src/prune.ts +58 -13
- package/src/router.ts +217 -17
- package/src/rules.js +329 -19
- package/src/sqlite.js +80 -0
- package/src/tasks.js +193 -40
- package/src/telemetry-store.ts +27 -4
- package/src/trust.js +182 -54
package/dist/index.js
CHANGED
|
@@ -16,10 +16,50 @@ var BASH_DENYLIST = [
|
|
|
16
16
|
// so opencode prompts for human approval before publishing.
|
|
17
17
|
"rm -rf *",
|
|
18
18
|
"rm -fr *",
|
|
19
|
+
// The short forms of the same flags, one at a time.
|
|
20
|
+
//
|
|
21
|
+
// The guard's rule is a TOKEN scanner: it blocks an rm whose short flag
|
|
22
|
+
// cluster contains r or f in any case, and an rm whose long flag is
|
|
23
|
+
// --recursive or --force. So `rm -f file` and `rm -r dir` are blocked
|
|
24
|
+
// there - and the permission map has to deny what the guard blocks, or
|
|
25
|
+
// the two layers contradict each other: the map would ask about a
|
|
26
|
+
// command the guard refuses.
|
|
27
|
+
//
|
|
28
|
+
// `-R` is named too because the guard's rule is case-insensitive and
|
|
29
|
+
// GNU rm reads `-R` as `--recursive`.
|
|
30
|
+
//
|
|
31
|
+
// The two layers are not the same matcher. The guard reads the tokens
|
|
32
|
+
// and can see a destructive flag in any position; this list is a flat
|
|
33
|
+
// glob over the command, so it can only name the spellings.
|
|
34
|
+
//
|
|
35
|
+
// A cluster a glob cannot express (`-rfv`) is caught by the guard and
|
|
36
|
+
// asked about by the config - the config asks about everything it does
|
|
37
|
+
// not name, and asking is the safe direction.
|
|
38
|
+
//
|
|
39
|
+
// A PERMUTED delete (`rm /tmp/x -rf`) is caught by neither layer today:
|
|
40
|
+
// the guard's rule needs the flag before the path, and the glob needs
|
|
41
|
+
// the flag in front. The config asks, so a human decides. Closing that
|
|
42
|
+
// hole is a change to the guard's rule in src/rules.js, which reads the
|
|
43
|
+
// tokens - a glob here could only name one spelling of it, and the
|
|
44
|
+
// spelling would still be the one it missed.
|
|
45
|
+
"rm -f *",
|
|
46
|
+
"rm -r *",
|
|
47
|
+
"rm -R *",
|
|
48
|
+
// The long GNU names of the same two flags. A delete that is spelled
|
|
49
|
+
// `--recursive --force` is the delete the short forms name.
|
|
50
|
+
"rm --recursive *",
|
|
51
|
+
"rm --force *",
|
|
19
52
|
"curl * | sh",
|
|
20
53
|
"curl * | bash",
|
|
54
|
+
// The same pipe into a shell that is named by its path. The shell is the
|
|
55
|
+
// same shell either way, and the config can only match what it can see.
|
|
56
|
+
"curl * | */sh",
|
|
57
|
+
"curl * | */bash",
|
|
21
58
|
"chmod 777 *",
|
|
22
59
|
"chmod 777*",
|
|
60
|
+
// The same mode written with a leading zero.
|
|
61
|
+
"chmod 0777 *",
|
|
62
|
+
"chmod 0777*",
|
|
23
63
|
"gh pr create*",
|
|
24
64
|
"gh pr merge*",
|
|
25
65
|
"gh pr close*",
|
|
@@ -45,7 +85,7 @@ var BASH_DENYLIST = [
|
|
|
45
85
|
];
|
|
46
86
|
function matchesPattern(command, pattern) {
|
|
47
87
|
const re = new RegExp(
|
|
48
|
-
"^" + pattern.replace(/[.+^${}()|[\]
|
|
88
|
+
"^" + pattern.replace(/[.+^${}()|[\]\\?]/g, "\\$&").replace(/\*/g, "[\\s\\S]*") + "$"
|
|
49
89
|
);
|
|
50
90
|
return re.test(command);
|
|
51
91
|
}
|
|
@@ -91,7 +131,7 @@ function heredocEnd(command, at) {
|
|
|
91
131
|
p = end + 1;
|
|
92
132
|
}
|
|
93
133
|
}
|
|
94
|
-
function
|
|
134
|
+
function split(command, kind) {
|
|
95
135
|
const parts = [];
|
|
96
136
|
let cur = "";
|
|
97
137
|
let quote = null;
|
|
@@ -129,13 +169,26 @@ function segments(command) {
|
|
|
129
169
|
continue;
|
|
130
170
|
}
|
|
131
171
|
}
|
|
132
|
-
if (ch === "
|
|
172
|
+
if (ch === "|") {
|
|
173
|
+
if (kind === "pipe") {
|
|
174
|
+
if (command[i + 1] === "|") {
|
|
175
|
+
cur += "||";
|
|
176
|
+
i++;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
flush();
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
133
182
|
flush();
|
|
183
|
+
if (command[i + 1] === "|") i++;
|
|
134
184
|
continue;
|
|
135
185
|
}
|
|
136
|
-
if (
|
|
186
|
+
if (kind === "pipe") {
|
|
187
|
+
cur += ch;
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (ch === "\n" || ch === ";") {
|
|
137
191
|
flush();
|
|
138
|
-
if (command[i + 1] === "|") i++;
|
|
139
192
|
continue;
|
|
140
193
|
}
|
|
141
194
|
if (ch === "&") {
|
|
@@ -156,6 +209,12 @@ function segments(command) {
|
|
|
156
209
|
flush();
|
|
157
210
|
return parts;
|
|
158
211
|
}
|
|
212
|
+
function segments(command) {
|
|
213
|
+
return split(command, "command");
|
|
214
|
+
}
|
|
215
|
+
function pipeStages(command) {
|
|
216
|
+
return split(command, "pipe");
|
|
217
|
+
}
|
|
159
218
|
function tokenize(segment) {
|
|
160
219
|
const tokens = [];
|
|
161
220
|
let cur = "";
|
|
@@ -211,6 +270,9 @@ var GIT_WRAPPERS = /* @__PURE__ */ new Set([
|
|
|
211
270
|
"xargs",
|
|
212
271
|
"npx"
|
|
213
272
|
]);
|
|
273
|
+
var SHELL_NAMES = /^(sh|bash|zsh|dash|ksh)(\.exe)?$/i;
|
|
274
|
+
var SCRIPT_RUNNERS = /^python[0-9.]*$/i;
|
|
275
|
+
var DOWNLOADERS = /\b(curl|wget)\b/i;
|
|
214
276
|
var WRAPPER_VALUE_FLAGS = /* @__PURE__ */ new Set(["-u", "--user", "-g", "--group", "-I", "-P", "-n", "-a", "-d"]);
|
|
215
277
|
var GIT_VALUE_FLAGS = /* @__PURE__ */ new Set([
|
|
216
278
|
"-C",
|
|
@@ -232,6 +294,35 @@ var GIT_GLOBAL_FLAGS = /* @__PURE__ */ new Set([
|
|
|
232
294
|
"--glob-pathspecs",
|
|
233
295
|
"--icase-pathspecs"
|
|
234
296
|
]);
|
|
297
|
+
var TOOL_VALUE_FLAGS = {
|
|
298
|
+
gh: /* @__PURE__ */ new Set(["-R", "--repo", "-H", "--hostname"]),
|
|
299
|
+
npm: /* @__PURE__ */ new Set([
|
|
300
|
+
"--prefix",
|
|
301
|
+
"--cache",
|
|
302
|
+
"--registry",
|
|
303
|
+
"--userconfig",
|
|
304
|
+
"--globalconfig",
|
|
305
|
+
"--otp",
|
|
306
|
+
"--tag",
|
|
307
|
+
"--access",
|
|
308
|
+
"--loglevel",
|
|
309
|
+
"--workspace",
|
|
310
|
+
"--script-shell",
|
|
311
|
+
"--init-author-name",
|
|
312
|
+
"--init-author-email",
|
|
313
|
+
"--init-author-url",
|
|
314
|
+
"--init-license",
|
|
315
|
+
"--init-module",
|
|
316
|
+
"--init-version"
|
|
317
|
+
]),
|
|
318
|
+
yarn: /* @__PURE__ */ new Set(["--cwd", "--registry", "--cache-folder", "--modules-folder"]),
|
|
319
|
+
pnpm: /* @__PURE__ */ new Set(["--dir", "-C", "--filter", "--store-dir", "--registry"]),
|
|
320
|
+
bun: /* @__PURE__ */ new Set(["--cwd", "--config", "--registry"])
|
|
321
|
+
};
|
|
322
|
+
function baseName(word) {
|
|
323
|
+
const slash = Math.max(word.lastIndexOf("/"), word.lastIndexOf("\\"));
|
|
324
|
+
return slash === -1 ? word : word.slice(slash + 1);
|
|
325
|
+
}
|
|
235
326
|
function skipPrefixes(tokens) {
|
|
236
327
|
let i = 0;
|
|
237
328
|
for (; ; ) {
|
|
@@ -241,7 +332,7 @@ function skipPrefixes(tokens) {
|
|
|
241
332
|
i++;
|
|
242
333
|
continue;
|
|
243
334
|
}
|
|
244
|
-
if (GIT_WRAPPERS.has(word)) {
|
|
335
|
+
if (GIT_WRAPPERS.has(baseName(word))) {
|
|
245
336
|
i++;
|
|
246
337
|
while (i < tokens.length && tokens[i].startsWith("-") && tokens[i] !== "-") {
|
|
247
338
|
const flag = tokens[i];
|
|
@@ -258,10 +349,25 @@ function stripPrefixes(segment) {
|
|
|
258
349
|
const i = skipPrefixes(tokens);
|
|
259
350
|
if (i >= tokens.length) return "";
|
|
260
351
|
const rest = tokens.slice(i);
|
|
261
|
-
|
|
262
|
-
if (slash !== -1) rest[0] = rest[0].slice(slash + 1);
|
|
352
|
+
rest[0] = baseName(rest[0]);
|
|
263
353
|
return rest.join(" ");
|
|
264
354
|
}
|
|
355
|
+
function stripToolFlags(command) {
|
|
356
|
+
const tokens = tokenize(command);
|
|
357
|
+
if (!tokens.length) return command;
|
|
358
|
+
const flags = TOOL_VALUE_FLAGS[baseName(tokens[0]).toLowerCase()];
|
|
359
|
+
if (!flags) return command;
|
|
360
|
+
const out = [];
|
|
361
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
362
|
+
const token = tokens[i];
|
|
363
|
+
if (!flags.has(token.split("=")[0])) {
|
|
364
|
+
out.push(token);
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
if (!token.includes("=") && i + 1 < tokens.length) i++;
|
|
368
|
+
}
|
|
369
|
+
return out.join(" ");
|
|
370
|
+
}
|
|
265
371
|
function gitSubcommand(segment) {
|
|
266
372
|
const tokens = tokenize(segment);
|
|
267
373
|
let i = skipPrefixes(tokens);
|
|
@@ -282,25 +388,87 @@ function gitSubcommand(segment) {
|
|
|
282
388
|
}
|
|
283
389
|
return tokens[i] ?? null;
|
|
284
390
|
}
|
|
285
|
-
var SHELL_BIN = /(^|[\\/])(sh|bash|zsh|dash|ksh)(\.exe)?$/i;
|
|
286
391
|
var MAX_SHELL_DEPTH = 2;
|
|
392
|
+
function shellCommandString(tokens, from) {
|
|
393
|
+
for (let n = from + 1; n < tokens.length; n++) {
|
|
394
|
+
const token = tokens[n];
|
|
395
|
+
if (!token.startsWith("-") || token === "-") continue;
|
|
396
|
+
const m = /^-([a-zA-Z]*)c/.exec(token);
|
|
397
|
+
if (!m) continue;
|
|
398
|
+
const after = token.slice(m[0].length);
|
|
399
|
+
if (after && !/^[a-zA-Z]+$/.test(after)) return after.trim();
|
|
400
|
+
return tokens.slice(n + 1).join(" ") || null;
|
|
401
|
+
}
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
287
404
|
function shellPayload(segment) {
|
|
288
405
|
const tokens = tokenize(segment);
|
|
289
406
|
if (tokens.length < 2) return null;
|
|
290
407
|
const i = skipPrefixes(tokens);
|
|
291
408
|
if (i >= tokens.length) return null;
|
|
292
409
|
if (tokens[i] === "eval") return tokens.slice(i + 1).join(" ") || null;
|
|
293
|
-
if (!
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
410
|
+
if (!SHELL_NAMES.test(baseName(tokens[i]))) return null;
|
|
411
|
+
return shellCommandString(tokens, i);
|
|
412
|
+
}
|
|
413
|
+
var GROUP_OPEN = /^[({[]+/;
|
|
414
|
+
var GROUP_END = /[;)&,}\]]+$/;
|
|
415
|
+
function commandName(word) {
|
|
416
|
+
return baseName(word).replace(GROUP_OPEN, "").replace(GROUP_END, "");
|
|
417
|
+
}
|
|
418
|
+
function runsScript(stage) {
|
|
419
|
+
for (const command of segments(stage)) {
|
|
420
|
+
const tokens = tokenize(command);
|
|
421
|
+
let i = skipPrefixes(tokens);
|
|
422
|
+
if (i >= tokens.length) continue;
|
|
423
|
+
let name = commandName(tokens[i]);
|
|
424
|
+
if (!name) {
|
|
425
|
+
i++;
|
|
426
|
+
if (i >= tokens.length) continue;
|
|
427
|
+
name = commandName(tokens[i]);
|
|
428
|
+
}
|
|
429
|
+
if (SHELL_NAMES.test(name) || SCRIPT_RUNNERS.test(name)) return true;
|
|
430
|
+
}
|
|
431
|
+
return false;
|
|
432
|
+
}
|
|
433
|
+
function pipesScriptIntoShell(command) {
|
|
434
|
+
const stages = pipeStages(command);
|
|
435
|
+
for (let i = 1; i < stages.length; i++) {
|
|
436
|
+
if (!DOWNLOADERS.test(stages[i - 1])) continue;
|
|
437
|
+
if (!runsScript(stages[i])) continue;
|
|
438
|
+
return true;
|
|
439
|
+
}
|
|
440
|
+
return false;
|
|
441
|
+
}
|
|
442
|
+
function isRecursiveDelete(text) {
|
|
443
|
+
const tokens = tokenize(text);
|
|
444
|
+
if (tokens.length < 2) return false;
|
|
445
|
+
if (baseName(tokens[0]).toLowerCase() !== "rm") return false;
|
|
446
|
+
for (let i = 1; i < tokens.length; i++) {
|
|
447
|
+
const token = tokens[i];
|
|
448
|
+
if (!token.startsWith("-")) continue;
|
|
449
|
+
if (!/^-[a-zA-Z]*[rf]/i.test(token) && !/^--(recursive|force)$/i.test(token)) continue;
|
|
450
|
+
return tokens.slice(i + 1).some((t) => !t.startsWith("-"));
|
|
451
|
+
}
|
|
452
|
+
return false;
|
|
297
453
|
}
|
|
298
454
|
var ASK_GATED = [/^\s*git\s+push\b/];
|
|
299
455
|
var BASH_RULES = [
|
|
300
456
|
{
|
|
301
457
|
name: "secret exfiltration via shell",
|
|
302
458
|
scope: "command",
|
|
303
|
-
|
|
459
|
+
// The credential word has to be the whole name or the tail of it: the
|
|
460
|
+
// name of the variable is `GITHUB_TOKEN`, not `GITHUB`. A word boundary
|
|
461
|
+
// never fires after an underscore, so the boundary alone left every
|
|
462
|
+
// prefixed credential name unmatched.
|
|
463
|
+
//
|
|
464
|
+
// The name is matched in whatever case it is written: the rule used to
|
|
465
|
+
// match the upper case spelling only, so `token=$(curl ...)` was
|
|
466
|
+
// allowed, and a lower case name is the same name.
|
|
467
|
+
//
|
|
468
|
+
// What the rule does NOT cover is an assignment whose value is not
|
|
469
|
+
// fetched: `GITHUB_TOKEN=ghp_example` sets a credential, it does not
|
|
470
|
+
// send one.
|
|
471
|
+
test: /(^|[^A-Za-z0-9])(?:[A-Za-z0-9]+_)*(TOKEN|SECRET|KEY|PASSWORD|API_KEY)\s*=\s*\$\(\s*(curl|wget)\b/i,
|
|
304
472
|
why: "assigns a fetched value into a credential-named variable"
|
|
305
473
|
},
|
|
306
474
|
{
|
|
@@ -312,7 +480,7 @@ var BASH_RULES = [
|
|
|
312
480
|
{
|
|
313
481
|
name: "pipe remote script into a shell",
|
|
314
482
|
scope: "command",
|
|
315
|
-
|
|
483
|
+
check: (text) => pipesScriptIntoShell(text),
|
|
316
484
|
why: "downloads and executes a script in one step"
|
|
317
485
|
},
|
|
318
486
|
// `git push` is ask-gated via permissions (`*` ask in the generated
|
|
@@ -321,13 +489,16 @@ var BASH_RULES = [
|
|
|
321
489
|
{
|
|
322
490
|
name: "recursive delete",
|
|
323
491
|
scope: "segment",
|
|
324
|
-
|
|
492
|
+
check: (text) => isRecursiveDelete(text),
|
|
325
493
|
why: "rm with a recursive or force flag"
|
|
326
494
|
},
|
|
327
495
|
{
|
|
328
496
|
name: "chmod 777",
|
|
329
497
|
scope: "segment",
|
|
330
|
-
|
|
498
|
+
// The mode may be written with leading zeros: `0777` and `777` are the
|
|
499
|
+
// same mode, and a rule that required the bare digits let one spelling
|
|
500
|
+
// of it through.
|
|
501
|
+
test: /^chmod\s+(-[a-zA-Z]+\s+)*0*777\b/,
|
|
331
502
|
why: "opens permissions on everything"
|
|
332
503
|
},
|
|
333
504
|
{
|
|
@@ -380,7 +551,7 @@ function checkDenied(command, depth = 0) {
|
|
|
380
551
|
const parts = segments(command);
|
|
381
552
|
const normalized = parts.map((seg) => stripPrefixes(seg));
|
|
382
553
|
for (let i = 0; i < parts.length; i++) {
|
|
383
|
-
const pattern = deniedBy(normalized[i]);
|
|
554
|
+
const pattern = deniedBy(stripToolFlags(normalized[i]));
|
|
384
555
|
if (pattern) return { pattern, segment: parts[i] };
|
|
385
556
|
}
|
|
386
557
|
if (depth < MAX_SHELL_DEPTH) {
|
|
@@ -433,15 +604,18 @@ function checkWrite(target, rules = WRITE_RULES, root = "") {
|
|
|
433
604
|
}
|
|
434
605
|
|
|
435
606
|
// src/telemetry-store.ts
|
|
436
|
-
import { appendFileSync, existsSync, mkdirSync, statSync, renameSync } from "node:fs";
|
|
607
|
+
import { appendFileSync, existsSync, mkdirSync, rmSync, statSync, renameSync } from "node:fs";
|
|
437
608
|
import { join } from "node:path";
|
|
438
609
|
var MAX_BYTES = 4 * 1024 * 1024;
|
|
439
610
|
function logPath(dir) {
|
|
440
611
|
return join(dir, "telemetry.jsonl");
|
|
441
612
|
}
|
|
442
|
-
function rotate(file) {
|
|
613
|
+
function rotate(file, maxBytes = MAX_BYTES) {
|
|
443
614
|
try {
|
|
444
|
-
if (existsSync(file) && statSync(file).size >
|
|
615
|
+
if (existsSync(file) && statSync(file).size > maxBytes) {
|
|
616
|
+
const oldest = `${file}.2`;
|
|
617
|
+
if (existsSync(oldest)) rmSync(oldest, { force: true });
|
|
618
|
+
if (existsSync(`${file}.1`)) renameSync(`${file}.1`, oldest);
|
|
445
619
|
renameSync(file, `${file}.1`);
|
|
446
620
|
}
|
|
447
621
|
} catch {
|
|
@@ -475,19 +649,46 @@ function projectRoot(directory, worktree) {
|
|
|
475
649
|
// src/guard.ts
|
|
476
650
|
var MARKER = "[opencode-harness-kit] blocked";
|
|
477
651
|
var WRITE_TOOLS = /* @__PURE__ */ new Set(["write", "edit", "apply_patch", "patch"]);
|
|
652
|
+
function hunkFollows(lines, from) {
|
|
653
|
+
for (let i = from; i < lines.length; i++) {
|
|
654
|
+
if (/^@@/.test(lines[i])) return true;
|
|
655
|
+
if (/^(?:---|\+\+\+)\s/.test(lines[i])) return false;
|
|
656
|
+
}
|
|
657
|
+
return false;
|
|
658
|
+
}
|
|
478
659
|
function pathsInPatch(text) {
|
|
479
660
|
const out = [];
|
|
480
661
|
const push = (p) => {
|
|
481
|
-
const v = p.trim();
|
|
662
|
+
const v = p.split(" ")[0].trim();
|
|
482
663
|
if (!v) return;
|
|
483
664
|
if (v === "/dev/null") return;
|
|
484
665
|
if (!out.includes(v)) out.push(v);
|
|
485
666
|
};
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
667
|
+
const lines = text.split("\n");
|
|
668
|
+
let seenHunk = false;
|
|
669
|
+
for (let i = 0; i < lines.length; i++) {
|
|
670
|
+
const line = lines[i];
|
|
671
|
+
const marker = /^\*\*\*\s+(?:Add|Update|Delete)\s+File:\s*(.+)$/.exec(line);
|
|
672
|
+
if (marker) {
|
|
673
|
+
push(marker[1]);
|
|
674
|
+
continue;
|
|
675
|
+
}
|
|
676
|
+
if (/^@@/.test(line)) {
|
|
677
|
+
seenHunk = true;
|
|
678
|
+
continue;
|
|
679
|
+
}
|
|
680
|
+
const minus = /^---\s+(?:a\/)?(.+)$/.exec(line);
|
|
681
|
+
const next = i + 1 < lines.length ? /^\+\+\+\s+(?:b\/)?(.+)$/.exec(lines[i + 1]) : null;
|
|
682
|
+
if (minus && next) {
|
|
683
|
+
if (hunkFollows(lines, i + 2)) {
|
|
684
|
+
push(minus[1]);
|
|
685
|
+
push(next[1]);
|
|
686
|
+
}
|
|
687
|
+
i++;
|
|
688
|
+
continue;
|
|
689
|
+
}
|
|
690
|
+
const plus = /^\+\+\+\s+(?:b\/)?(.+)$/.exec(line);
|
|
691
|
+
if (plus && !seenHunk) push(plus[1]);
|
|
491
692
|
}
|
|
492
693
|
return out;
|
|
493
694
|
}
|
|
@@ -653,6 +854,11 @@ function loadProfile(id) {
|
|
|
653
854
|
const msg = err instanceof Error ? err.message : String(err);
|
|
654
855
|
throw new ProfileError(`cannot read ${path}: ${msg}`);
|
|
655
856
|
}
|
|
857
|
+
if (raw?.id && raw.id !== id) {
|
|
858
|
+
throw new ProfileError(
|
|
859
|
+
`${path}: profile id is "${raw.id}", which does not match the file name "${id}"`
|
|
860
|
+
);
|
|
861
|
+
}
|
|
656
862
|
validate(raw, path);
|
|
657
863
|
return normalize(raw);
|
|
658
864
|
}
|
|
@@ -817,6 +1023,10 @@ function resolveLaneChain(p, role) {
|
|
|
817
1023
|
return chain;
|
|
818
1024
|
}
|
|
819
1025
|
function resolveLaneModel(p, role, opts = {}) {
|
|
1026
|
+
const model = laneModelId(p, role, opts);
|
|
1027
|
+
return { model, levels: levelsOfModel(model) };
|
|
1028
|
+
}
|
|
1029
|
+
function laneModelId(p, role, opts = {}) {
|
|
820
1030
|
const unhealthy = opts.unhealthy ?? /* @__PURE__ */ new Set();
|
|
821
1031
|
const ceiling = (
|
|
822
1032
|
/** @type {Retention} */
|
|
@@ -825,13 +1035,12 @@ function resolveLaneModel(p, role, opts = {}) {
|
|
|
825
1035
|
for (const candidate of resolveLaneChain(p, role)) {
|
|
826
1036
|
if (unhealthy.has(candidate)) continue;
|
|
827
1037
|
if (!retentionAllowed(candidate, ceiling)) continue;
|
|
828
|
-
return
|
|
1038
|
+
return candidate;
|
|
829
1039
|
}
|
|
830
1040
|
if (p.model && !unhealthy.has(p.model) && retentionAllowed(p.model, ceiling)) {
|
|
831
|
-
return
|
|
1041
|
+
return p.model;
|
|
832
1042
|
}
|
|
833
|
-
|
|
834
|
-
return { model: fallback, levels: levelsOfModel(fallback) };
|
|
1043
|
+
return resolveLaneChain(p, role)[0] ?? p.model;
|
|
835
1044
|
}
|
|
836
1045
|
function levelsOfModel(model) {
|
|
837
1046
|
try {
|
|
@@ -864,48 +1073,66 @@ function resolveEffort(levels, requested) {
|
|
|
864
1073
|
if (!levels || levels.length === 0) return null;
|
|
865
1074
|
if (levels.includes(requested)) return requested;
|
|
866
1075
|
if (requested === "none") return null;
|
|
1076
|
+
const rungs = levels.filter(
|
|
1077
|
+
(level) => level !== "none" && CANON.indexOf(level) >= 0
|
|
1078
|
+
);
|
|
867
1079
|
const want = CANON.indexOf(requested);
|
|
868
1080
|
if (want < 0) {
|
|
869
|
-
return
|
|
1081
|
+
return rungs[rungs.length - 1] ?? null;
|
|
870
1082
|
}
|
|
871
1083
|
let best = null;
|
|
872
1084
|
let bestDist = Infinity;
|
|
873
|
-
for (const level of
|
|
1085
|
+
for (const level of rungs) {
|
|
874
1086
|
const have = CANON.indexOf(level);
|
|
875
|
-
if (have < 0) continue;
|
|
876
1087
|
const d = Math.abs(have - want);
|
|
877
1088
|
if (d < bestDist) {
|
|
878
1089
|
bestDist = d;
|
|
879
1090
|
best = level;
|
|
880
1091
|
}
|
|
881
1092
|
}
|
|
882
|
-
return best ??
|
|
1093
|
+
return best ?? rungs[rungs.length - 1] ?? null;
|
|
883
1094
|
}
|
|
884
|
-
var
|
|
1095
|
+
var DECLARABLE_RETENTION = ["zero-retention", "retained", "may-train"];
|
|
885
1096
|
function validate(p, path = "<memory>") {
|
|
886
1097
|
const where = p?.id ?? path;
|
|
887
1098
|
if (!p?.id) throw new ProfileError(`${path}: missing "id"`);
|
|
888
1099
|
if (!p.provider) throw new ProfileError(`${where}: missing "provider"`);
|
|
889
1100
|
if (!p.model) throw new ProfileError(`${where}: missing "model"`);
|
|
890
|
-
if (
|
|
891
|
-
throw new ProfileError(
|
|
892
|
-
`${where}: retention must be one of ${VALID_RETENTION.join(", ")}`
|
|
893
|
-
);
|
|
1101
|
+
if (p.retention === "unknown") {
|
|
1102
|
+
throw new ProfileError(`${where}: retention cannot be unknown`);
|
|
894
1103
|
}
|
|
895
|
-
if (
|
|
1104
|
+
if (!DECLARABLE_RETENTION.includes(p.retention)) {
|
|
896
1105
|
throw new ProfileError(
|
|
897
|
-
`${where}:
|
|
1106
|
+
`${where}: retention must be one of ${DECLARABLE_RETENTION.join(", ")}`
|
|
898
1107
|
);
|
|
899
1108
|
}
|
|
900
1109
|
if (p.maxRetention === "unknown") {
|
|
901
1110
|
throw new ProfileError(`${where}: maxRetention cannot be unknown`);
|
|
902
1111
|
}
|
|
903
|
-
if (
|
|
1112
|
+
if (p.maxRetention && !DECLARABLE_RETENTION.includes(p.maxRetention)) {
|
|
1113
|
+
throw new ProfileError(
|
|
1114
|
+
`${where}: maxRetention must be one of ${DECLARABLE_RETENTION.join(", ")}`
|
|
1115
|
+
);
|
|
1116
|
+
}
|
|
1117
|
+
if (!Number.isInteger(p.limits?.context) || p.limits.context <= 0) {
|
|
904
1118
|
throw new ProfileError(`${where}: limits.context must be a positive integer`);
|
|
905
1119
|
}
|
|
906
|
-
if (!p.limits?.output || p.limits.output <= 0) {
|
|
1120
|
+
if (!Number.isInteger(p.limits?.output) || p.limits.output <= 0) {
|
|
907
1121
|
throw new ProfileError(`${where}: limits.output must be a positive integer`);
|
|
908
1122
|
}
|
|
1123
|
+
for (const level of p.effort?.levels ?? []) {
|
|
1124
|
+
if (!CANON.includes(level)) {
|
|
1125
|
+
throw new ProfileError(
|
|
1126
|
+
`${where}: effort.levels contains "${level}", which is not one of ${CANON.join(", ")}`
|
|
1127
|
+
);
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
const wanted = p.effort?.default;
|
|
1131
|
+
if (wanted !== void 0 && wanted !== null && !CANON.includes(wanted)) {
|
|
1132
|
+
throw new ProfileError(
|
|
1133
|
+
`${where}: effort.default is "${wanted}", which is not one of ${CANON.join(", ")}`
|
|
1134
|
+
);
|
|
1135
|
+
}
|
|
909
1136
|
const share = p.effort?.reserveShare;
|
|
910
1137
|
if (share !== void 0 && (share < 0 || share > 0.5)) {
|
|
911
1138
|
throw new ProfileError(
|
|
@@ -1004,6 +1231,11 @@ function validate(p, path = "<memory>") {
|
|
|
1004
1231
|
);
|
|
1005
1232
|
}
|
|
1006
1233
|
}
|
|
1234
|
+
if (!retentionFor(p.model)) {
|
|
1235
|
+
throw new ProfileError(
|
|
1236
|
+
`${where}: model ${p.model} has no retention.yml entry`
|
|
1237
|
+
);
|
|
1238
|
+
}
|
|
1007
1239
|
if (p.utility) {
|
|
1008
1240
|
if (typeof p.utility.model !== "string" || !p.utility.model) {
|
|
1009
1241
|
throw new ProfileError(`${where}: utility.model must be a model id`);
|
|
@@ -1018,6 +1250,11 @@ function validate(p, path = "<memory>") {
|
|
|
1018
1250
|
`${where}: utility.model is ${p.utility.model}, which this profile does not resolve to (it has ${[...reachable].join(", ")})`
|
|
1019
1251
|
);
|
|
1020
1252
|
}
|
|
1253
|
+
if (!retentionFor(p.utility.model)) {
|
|
1254
|
+
throw new ProfileError(
|
|
1255
|
+
`${where}: utility.model ${p.utility.model} has no retention.yml entry`
|
|
1256
|
+
);
|
|
1257
|
+
}
|
|
1021
1258
|
}
|
|
1022
1259
|
for (const id of p.fallback ?? []) {
|
|
1023
1260
|
if (!profileExists(id)) {
|
|
@@ -1026,6 +1263,12 @@ function validate(p, path = "<memory>") {
|
|
|
1026
1263
|
);
|
|
1027
1264
|
}
|
|
1028
1265
|
}
|
|
1266
|
+
const actual = worstRetention(laneModelsInPlay(p));
|
|
1267
|
+
if (retentionSeverity(p.retention) < retentionSeverity(actual.retention)) {
|
|
1268
|
+
throw new ProfileError(
|
|
1269
|
+
`${where}: retention is ${p.retention}, but ${actual.worst} is ${actual.retention} - a profile cannot be more private than the models it runs on`
|
|
1270
|
+
);
|
|
1271
|
+
}
|
|
1029
1272
|
}
|
|
1030
1273
|
function profileExists(id) {
|
|
1031
1274
|
return profilePath(id) !== null;
|
|
@@ -1045,6 +1288,14 @@ function reservedTokens(p, opts = {}) {
|
|
|
1045
1288
|
function pruneThreshold(p, opts = {}) {
|
|
1046
1289
|
return inputCap(p, opts) - reservedTokens(p, opts);
|
|
1047
1290
|
}
|
|
1291
|
+
function laneModelsInPlay(p, opts = {}) {
|
|
1292
|
+
const out = [p.model];
|
|
1293
|
+
for (const role of Object.keys(p.roleModels ?? {})) {
|
|
1294
|
+
const model = laneModelId(p, role, opts);
|
|
1295
|
+
if (!out.includes(model)) out.push(model);
|
|
1296
|
+
}
|
|
1297
|
+
return out;
|
|
1298
|
+
}
|
|
1048
1299
|
function roleSplitModels(p, opts = {}) {
|
|
1049
1300
|
return Object.entries(p.roleModels ?? {}).map(([role]) => {
|
|
1050
1301
|
const resolved = resolveLaneModel(p, role, opts);
|
|
@@ -1057,6 +1308,18 @@ function roleSplitModels(p, opts = {}) {
|
|
|
1057
1308
|
return { role, model: resolved.model, levels: resolved.levels, limits };
|
|
1058
1309
|
});
|
|
1059
1310
|
}
|
|
1311
|
+
function worstRetention(ids) {
|
|
1312
|
+
let worst = null;
|
|
1313
|
+
let retention = "zero-retention";
|
|
1314
|
+
for (const id of ids) {
|
|
1315
|
+
const declared = modelRetention(id);
|
|
1316
|
+
if (!worst || retentionSeverity(declared) > retentionSeverity(retention)) {
|
|
1317
|
+
worst = id;
|
|
1318
|
+
retention = declared;
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
return { retention, worst };
|
|
1322
|
+
}
|
|
1060
1323
|
function configDir() {
|
|
1061
1324
|
return process.env.OPENCODE_CONFIG_DIR ?? join4(homedir3(), ".config", "opencode");
|
|
1062
1325
|
}
|
|
@@ -1181,10 +1444,13 @@ function toolSignature(part) {
|
|
|
1181
1444
|
}
|
|
1182
1445
|
return `${tool} ${input}`;
|
|
1183
1446
|
}
|
|
1184
|
-
function
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1447
|
+
function partSize(part) {
|
|
1448
|
+
try {
|
|
1449
|
+
const json = JSON.stringify(part);
|
|
1450
|
+
return typeof json === "string" ? json.length : 0;
|
|
1451
|
+
} catch {
|
|
1452
|
+
return 0;
|
|
1453
|
+
}
|
|
1188
1454
|
}
|
|
1189
1455
|
function stub(part) {
|
|
1190
1456
|
const tool = part?.tool ?? "tool";
|
|
@@ -1282,7 +1548,7 @@ var PrunePlugin = async ({ worktree, directory }) => {
|
|
|
1282
1548
|
if (!msg?.parts) continue;
|
|
1283
1549
|
const isUser = msg.info?.role === "user";
|
|
1284
1550
|
for (const part of msg.parts) {
|
|
1285
|
-
const size =
|
|
1551
|
+
const size = partSize(part);
|
|
1286
1552
|
stats.bytesBefore += size;
|
|
1287
1553
|
if (isUser && keepUsers) {
|
|
1288
1554
|
stats.bytesAfter += size;
|
|
@@ -1302,7 +1568,7 @@ var PrunePlugin = async ({ worktree, directory }) => {
|
|
|
1302
1568
|
break;
|
|
1303
1569
|
case "reasoning": {
|
|
1304
1570
|
part.text = REASONING_STUB;
|
|
1305
|
-
stats.bytesAfter +=
|
|
1571
|
+
stats.bytesAfter += partSize(part);
|
|
1306
1572
|
stats.stubbed++;
|
|
1307
1573
|
break;
|
|
1308
1574
|
}
|
|
@@ -1321,7 +1587,7 @@ var PrunePlugin = async ({ worktree, directory }) => {
|
|
|
1321
1587
|
time: state.time,
|
|
1322
1588
|
metadata: { ...state.metadata ?? {}, pruned: true, prunedAt: (/* @__PURE__ */ new Date()).toISOString() }
|
|
1323
1589
|
};
|
|
1324
|
-
stats.bytesAfter +=
|
|
1590
|
+
stats.bytesAfter += partSize(part);
|
|
1325
1591
|
stats.stubbed++;
|
|
1326
1592
|
break;
|
|
1327
1593
|
}
|
|
@@ -1336,7 +1602,7 @@ var PrunePlugin = async ({ worktree, directory }) => {
|
|
|
1336
1602
|
}
|
|
1337
1603
|
}
|
|
1338
1604
|
if (stats.stubbed > 0) {
|
|
1339
|
-
const saved =
|
|
1605
|
+
const saved = stats.bytesBefore - stats.bytesAfter;
|
|
1340
1606
|
await logPrune(project, profile.id, {
|
|
1341
1607
|
...stats,
|
|
1342
1608
|
saved,
|
|
@@ -1394,27 +1660,67 @@ function utilityModel(p) {
|
|
|
1394
1660
|
// src/health.js
|
|
1395
1661
|
import { existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync } from "node:fs";
|
|
1396
1662
|
import { join as join6 } from "node:path";
|
|
1663
|
+
var DEFAULT_WINDOW_MS = 24 * 60 * 60 * 1e3;
|
|
1664
|
+
function spentWindowMs() {
|
|
1665
|
+
const raw = process.env.HARNESS_SPENT_WINDOW_HOURS;
|
|
1666
|
+
if (!raw) return DEFAULT_WINDOW_MS;
|
|
1667
|
+
const hours = Number(raw);
|
|
1668
|
+
if (!Number.isFinite(hours) || hours <= 0) return DEFAULT_WINDOW_MS;
|
|
1669
|
+
return hours * 60 * 60 * 1e3;
|
|
1670
|
+
}
|
|
1671
|
+
function isSpent(entry, now = Date.now()) {
|
|
1672
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return false;
|
|
1673
|
+
const at = (
|
|
1674
|
+
/** @type {any} */
|
|
1675
|
+
entry.at
|
|
1676
|
+
);
|
|
1677
|
+
if (typeof at !== "string") return true;
|
|
1678
|
+
const when = Date.parse(at);
|
|
1679
|
+
if (Number.isNaN(when)) return true;
|
|
1680
|
+
return now - when <= spentWindowMs();
|
|
1681
|
+
}
|
|
1682
|
+
function liveUnhealthy(health, now = Date.now()) {
|
|
1683
|
+
const out = {};
|
|
1684
|
+
const source = health?.unhealthy;
|
|
1685
|
+
if (!source || typeof source !== "object" || Array.isArray(source)) return out;
|
|
1686
|
+
for (const [key, entry] of Object.entries(source)) {
|
|
1687
|
+
if (isSpent(entry, now)) out[key] = entry;
|
|
1688
|
+
}
|
|
1689
|
+
return out;
|
|
1690
|
+
}
|
|
1397
1691
|
function healthPath(dir) {
|
|
1398
1692
|
return join6(dir, ".space", "health.json");
|
|
1399
1693
|
}
|
|
1400
1694
|
function readHealth(dir) {
|
|
1695
|
+
let parsed;
|
|
1401
1696
|
try {
|
|
1402
|
-
|
|
1697
|
+
parsed = JSON.parse(readFileSync3(healthPath(dir), "utf8"));
|
|
1403
1698
|
} catch {
|
|
1404
1699
|
return { unhealthy: {} };
|
|
1405
1700
|
}
|
|
1701
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
1702
|
+
return { unhealthy: {} };
|
|
1703
|
+
}
|
|
1704
|
+
const unhealthy = parsed.unhealthy;
|
|
1705
|
+
if (!unhealthy || typeof unhealthy !== "object" || Array.isArray(unhealthy)) {
|
|
1706
|
+
return { ...parsed, unhealthy: {} };
|
|
1707
|
+
}
|
|
1708
|
+
return parsed;
|
|
1406
1709
|
}
|
|
1407
1710
|
function writeHealth(dir, health) {
|
|
1408
1711
|
try {
|
|
1409
1712
|
if (!existsSync5(join6(dir, ".space"))) mkdirSync2(join6(dir, ".space"), { recursive: true });
|
|
1410
|
-
|
|
1713
|
+
const pruned = { ...health ?? {}, unhealthy: liveUnhealthy(health) };
|
|
1714
|
+
writeFileSync(healthPath(dir), JSON.stringify(pruned, null, 2));
|
|
1411
1715
|
} catch {
|
|
1412
1716
|
}
|
|
1413
1717
|
}
|
|
1718
|
+
function unhealthyRoutes(dir) {
|
|
1719
|
+
return liveUnhealthy(readHealth(dir));
|
|
1720
|
+
}
|
|
1414
1721
|
function unhealthyModels(dir) {
|
|
1415
|
-
const health = readHealth(dir);
|
|
1416
1722
|
const out = /* @__PURE__ */ new Set();
|
|
1417
|
-
for (const key of Object.keys(
|
|
1723
|
+
for (const key of Object.keys(unhealthyRoutes(dir))) {
|
|
1418
1724
|
out.add(key.includes("/") ? key.slice(key.indexOf("/") + 1) : key);
|
|
1419
1725
|
}
|
|
1420
1726
|
return out;
|
|
@@ -1427,24 +1733,94 @@ var SPENT = [
|
|
|
1427
1733
|
/\b429\b/,
|
|
1428
1734
|
/quota (?:exceeded|exhausted)/i,
|
|
1429
1735
|
/overloaded_error/i,
|
|
1430
|
-
|
|
1736
|
+
// A withdrawn model, named in the error. The words have to sit next to
|
|
1737
|
+
// each other - "model minimax-m3.1-flash-free not found", "model_not_found",
|
|
1738
|
+
// "ModelNotFound" - because the text this runs against is the provider's
|
|
1739
|
+
// own error and that can be a whole response body. The old pattern ran
|
|
1740
|
+
// over the entire event, where a body that mentioned a model in one place
|
|
1741
|
+
// and "not found" in another read as a withdrawn model: a live route
|
|
1742
|
+
// written off by a coincidence.
|
|
1743
|
+
/\bmodel[\W_]*not[\W_]*found\b/i,
|
|
1744
|
+
/\bmodel\b[\w.:/-]*(?:\s+[\w.:/-]+){0,2}\s+not found\b/i,
|
|
1431
1745
|
/no available provider/i
|
|
1432
1746
|
];
|
|
1433
|
-
function
|
|
1747
|
+
function errorText(err) {
|
|
1748
|
+
if (err == null) return "";
|
|
1749
|
+
if (typeof err === "string") return err;
|
|
1750
|
+
if (typeof err !== "object") return String(err);
|
|
1751
|
+
const parts = [];
|
|
1752
|
+
try {
|
|
1753
|
+
const push = (v) => {
|
|
1754
|
+
if (typeof v === "string" && v) parts.push(v);
|
|
1755
|
+
else if (typeof v === "number" && Number.isFinite(v)) parts.push(String(v));
|
|
1756
|
+
else if (v && typeof v === "object") parts.push(safeStringify(v));
|
|
1757
|
+
};
|
|
1758
|
+
push(err.name);
|
|
1759
|
+
push(err.message);
|
|
1760
|
+
push(err.code);
|
|
1761
|
+
const data = err.data;
|
|
1762
|
+
if (data && typeof data === "object") {
|
|
1763
|
+
push(data.name);
|
|
1764
|
+
push(data.message);
|
|
1765
|
+
push(data.code);
|
|
1766
|
+
push(data.statusCode);
|
|
1767
|
+
push(data.responseBody);
|
|
1768
|
+
}
|
|
1769
|
+
} catch {
|
|
1770
|
+
}
|
|
1771
|
+
return parts.join("\n");
|
|
1772
|
+
}
|
|
1773
|
+
function classifyError(err) {
|
|
1774
|
+
const text = errorText(err);
|
|
1775
|
+
if (!text) return null;
|
|
1434
1776
|
for (const p of SPENT) if (p.test(text)) return p.source;
|
|
1435
1777
|
return null;
|
|
1436
1778
|
}
|
|
1779
|
+
function errorOf(e) {
|
|
1780
|
+
const props = e?.properties;
|
|
1781
|
+
if (!props || typeof props !== "object") return void 0;
|
|
1782
|
+
return props.info?.error ?? props.error;
|
|
1783
|
+
}
|
|
1784
|
+
function routeKey(provider, model) {
|
|
1785
|
+
const p = typeof provider === "string" && provider ? provider : "opencode";
|
|
1786
|
+
return `${p}/${model}`;
|
|
1787
|
+
}
|
|
1788
|
+
function routeOf(e) {
|
|
1789
|
+
const props = e?.properties;
|
|
1790
|
+
if (!props || typeof props !== "object") return void 0;
|
|
1791
|
+
const info = props.info ?? props;
|
|
1792
|
+
if (!info || typeof info !== "object") return void 0;
|
|
1793
|
+
const model = info.model;
|
|
1794
|
+
const id = model?.modelID ?? info.modelID ?? props.modelID ?? props.model?.modelID;
|
|
1795
|
+
if (typeof id !== "string" || !id) return void 0;
|
|
1796
|
+
const provider = model?.providerID ?? info.providerID ?? props.providerID;
|
|
1797
|
+
return routeKey(typeof provider === "string" ? provider : void 0, id);
|
|
1798
|
+
}
|
|
1799
|
+
function sessionIdOf(e) {
|
|
1800
|
+
const props = e?.properties;
|
|
1801
|
+
const id = props?.info?.sessionID ?? props?.sessionID;
|
|
1802
|
+
return typeof id === "string" && id ? id : void 0;
|
|
1803
|
+
}
|
|
1804
|
+
function routeOfConfig(cfg) {
|
|
1805
|
+
const model = cfg?.model ?? cfg?.harness?.sessionModel;
|
|
1806
|
+
if (typeof model !== "string" || !model) return void 0;
|
|
1807
|
+
return model.includes("/") ? model : `opencode/${model}`;
|
|
1808
|
+
}
|
|
1437
1809
|
var WARN_TEXT = "[opencode-harness-kit] This session's model route is rate limited or withdrawn. No plugin can swap a live session's model, so stop retrying and ask the human to start a new session. Run `oc-harness list` to see the lanes and their chains, or `oc-harness doctor` to check whether the profile still matches live metadata.";
|
|
1438
1810
|
var RouterPlugin = async ({ worktree, directory }) => {
|
|
1439
1811
|
const project = projectRoot(directory, worktree);
|
|
1440
1812
|
let failedSessions = 0;
|
|
1441
1813
|
let warned = false;
|
|
1814
|
+
const sessionRoutes = /* @__PURE__ */ new Map();
|
|
1815
|
+
let lastRoute;
|
|
1816
|
+
let configuredRoute;
|
|
1442
1817
|
return {
|
|
1443
1818
|
// Runs once, before the session does any work.
|
|
1444
1819
|
config: async (config) => {
|
|
1445
1820
|
try {
|
|
1446
1821
|
const cfg = config;
|
|
1447
1822
|
const health = readHealth(project);
|
|
1823
|
+
const routes = unhealthyRoutes(project);
|
|
1448
1824
|
const unhealthy = unhealthyModels(project);
|
|
1449
1825
|
const activeId = cfg.harness?.profile ?? profileIdFromConfig();
|
|
1450
1826
|
if (!activeId) return;
|
|
@@ -1455,8 +1831,8 @@ var RouterPlugin = async ({ worktree, directory }) => {
|
|
|
1455
1831
|
} catch {
|
|
1456
1832
|
return;
|
|
1457
1833
|
}
|
|
1458
|
-
const baseKey =
|
|
1459
|
-
if (
|
|
1834
|
+
const baseKey = routeKey(profile.provider, profile.model);
|
|
1835
|
+
if (routes[baseKey]) {
|
|
1460
1836
|
const queue = [id, ...profile.fallback ?? []];
|
|
1461
1837
|
for (const candidate of queue) {
|
|
1462
1838
|
let fallback;
|
|
@@ -1465,8 +1841,8 @@ var RouterPlugin = async ({ worktree, directory }) => {
|
|
|
1465
1841
|
} catch {
|
|
1466
1842
|
continue;
|
|
1467
1843
|
}
|
|
1468
|
-
const key =
|
|
1469
|
-
if (
|
|
1844
|
+
const key = routeKey(fallback.provider, fallback.model);
|
|
1845
|
+
if (routes[key]) continue;
|
|
1470
1846
|
const fleet = isFleet(profile);
|
|
1471
1847
|
applySessionModel(cfg, fallback, {
|
|
1472
1848
|
keepLanes: fleet,
|
|
@@ -1481,6 +1857,7 @@ var RouterPlugin = async ({ worktree, directory }) => {
|
|
|
1481
1857
|
break;
|
|
1482
1858
|
}
|
|
1483
1859
|
}
|
|
1860
|
+
configuredRoute = routeOfConfig(cfg) ?? routeKey(profile.provider, profile.model);
|
|
1484
1861
|
const roles = resolveRoles(profile, { unhealthy });
|
|
1485
1862
|
for (const [lane, level] of Object.entries(roles)) {
|
|
1486
1863
|
if (level === void 0) continue;
|
|
@@ -1488,7 +1865,7 @@ var RouterPlugin = async ({ worktree, directory }) => {
|
|
|
1488
1865
|
if (!agent) continue;
|
|
1489
1866
|
const resolved = resolveLaneModel(profile, lane, { unhealthy });
|
|
1490
1867
|
if (resolved.model === profile.model && !profile.roleModels?.[lane]) continue;
|
|
1491
|
-
const wantModel =
|
|
1868
|
+
const wantModel = routeKey(profile.provider, resolved.model);
|
|
1492
1869
|
if (agent.model !== wantModel) {
|
|
1493
1870
|
agent.model = wantModel;
|
|
1494
1871
|
const requested = profile.roles?.[lane] ?? null;
|
|
@@ -1504,14 +1881,21 @@ var RouterPlugin = async ({ worktree, directory }) => {
|
|
|
1504
1881
|
const e = event;
|
|
1505
1882
|
const type = e?.type;
|
|
1506
1883
|
if (!type) return;
|
|
1507
|
-
const
|
|
1508
|
-
|
|
1884
|
+
const seen = routeOf(e);
|
|
1885
|
+
if (seen) {
|
|
1886
|
+
lastRoute = seen;
|
|
1887
|
+
const sessionID2 = sessionIdOf(e);
|
|
1888
|
+
if (sessionID2) sessionRoutes.set(sessionID2, seen);
|
|
1889
|
+
}
|
|
1890
|
+
const err = errorOf(e);
|
|
1891
|
+
if (!err) return;
|
|
1892
|
+
const reason = classifyError(err);
|
|
1509
1893
|
if (!reason) return;
|
|
1510
|
-
const
|
|
1511
|
-
const key =
|
|
1894
|
+
const sessionID = sessionIdOf(e);
|
|
1895
|
+
const key = seen ?? (sessionID ? sessionRoutes.get(sessionID) : void 0) ?? configuredRoute ?? lastRoute;
|
|
1512
1896
|
if (!key) return;
|
|
1513
1897
|
const health = readHealth(project);
|
|
1514
|
-
if (
|
|
1898
|
+
if (unhealthyRoutes(project)[key]) return;
|
|
1515
1899
|
health.unhealthy[key] = { at: (/* @__PURE__ */ new Date()).toISOString(), reason };
|
|
1516
1900
|
writeHealth(project, health);
|
|
1517
1901
|
failedSessions++;
|