@lawaty/lacode 2.2.0-beta.0 → 2.3.0-beta.0
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 +29 -0
- package/README.md +14 -0
- package/dist/agents.js +2 -0
- package/dist/commands.js +2 -0
- package/dist/index.js +2472 -2118
- package/dist/lacode.js +2 -0
- package/dist/pool.js +154 -23
- package/dist/tui.js +153 -25
- package/dist/tunnel.js +2 -0
- package/docs/CONFIGURATION.md +12 -0
- package/docs/USER-STORIES.md +56 -8
- package/package.json +1 -1
- package/scripts/pool-selection.mjs +109 -15
- package/scripts/refresh-pool.mjs +72 -9
- package/scripts/score-models.mjs +151 -47
package/docs/USER-STORIES.md
CHANGED
|
@@ -30,6 +30,7 @@ document; everything here is testable offline with a stub client and a
|
|
|
30
30
|
| **E — Remote hosts** | Remote SSH notification. Work runs on remote hosts, so notifications raised there die on that host's nonexistent desktop instead of reaching the local one. |
|
|
31
31
|
| **F — Adoption and safety** | Installability. A personal config cannot be adopted safely: it either clobbers what you already have, or needs manual surgery before it runs. |
|
|
32
32
|
| **G — Context and cost** | Cold exploration. Every task starts by re-discovering the structure of the codebase from scratch, so the same layout is re-read — and re-paid for — on every single run. |
|
|
33
|
+
| **K — Autonomy modes** | Stalls. A long run stops for every routine permission, or waits for a human to answer a question the agent could decide itself, even when its own recommendation is clear. |
|
|
33
34
|
|
|
34
35
|
---
|
|
35
36
|
|
|
@@ -88,8 +89,9 @@ The reconciliation with US-7 is that **acceptance and endorsement are different
|
|
|
88
89
|
**Story ID(s) for tests:** US-4
|
|
89
90
|
**Acceptance criteria:**
|
|
90
91
|
- [ ] Given a spawn fails with a rate-limit-shaped error, When the error is serialized and matched, Then the offending model is marked cooling and a cooldown file is written for it.
|
|
91
|
-
- [ ] Given model A is cooling, When the next spawn is routed, Then the pick is a different model
|
|
92
|
-
- [ ] Given
|
|
92
|
+
- [ ] Given model A is cooling and another slot is usable, When the next spawn is routed, Then the pick is a different usable model, never the cooling one.
|
|
93
|
+
- [ ] Given a pooled task completes with an empty result, When the task hook returns, Then that model is benched for `empty response`, the result explains the bench, and the next spawn routes to another slot.
|
|
94
|
+
- [ ] Given every model is cooling, When a spawn is routed, Then the spawn is refused explicitly with a message naming the cooling slots and their reasons, and no model is routed to.
|
|
93
95
|
- [ ] Given a limit error names a reset time, When the cooldown is recorded, Then the stated reset is used instead of a guessed backoff.
|
|
94
96
|
|
|
95
97
|
### US-5: Slots fill in declaration order in one pass, with repeats
|
|
@@ -101,7 +103,7 @@ The reconciliation with US-7 is that **acceptance and endorsement are different
|
|
|
101
103
|
- [ ] Given a model appears on more than one line, When the pool loads, Then each line is its own slot with its own weight, and the later line adds seats at its own position.
|
|
102
104
|
- [ ] Given a slot is cooling or unavailable, When its position comes up, Then it is skipped and the pass continues to the next slot.
|
|
103
105
|
- [ ] Given a claim is released, When the next spawn is routed, Then the slot that freed capacity is preferred again in declaration order; no stored position keeps it waiting.
|
|
104
|
-
- [ ] Given every usable slot is at its weight, When another spawn is routed, Then it goes to the least-loaded usable slot as overflow, and the pool never refuses work.
|
|
106
|
+
- [ ] Given every usable slot is at its weight, When another spawn is routed, Then it goes to the least-loaded usable slot as overflow, and the pool never refuses work while at least one slot is usable.
|
|
105
107
|
- [ ] Given all slots carry equal weight, When spawns are routed, Then the same ordered one-pass fill applies.
|
|
106
108
|
- [ ] Given per-slot load is read, When it is asserted, Then a slot's load never exceeds its weight except through overflow.
|
|
107
109
|
|
|
@@ -167,6 +169,20 @@ A limit does not always arrive as an error. Sometimes the provider accepts the r
|
|
|
167
169
|
- [ ] Given a provider reports a transient upstream outage rather than a limit, When the status is handled, Then the model is cooled for one short flat window instead of the quota window, and the session is still aborted, because an outage lifts on its own and must not bench a healthy model.
|
|
168
170
|
- [ ] Given a transient outage was cooled, When the same model later fails with a real limit, Then the cooldown starts at the first strike rather than an escalated one, because an outage lifts on its own and must not make the next quota window longer.
|
|
169
171
|
|
|
172
|
+
### US-39: The pool refresh completes, retries what failed, and never starves
|
|
173
|
+
**As a** user relying on the scored pool, **I want** the refresh to finish even when the machine reboots, **so that** the pool is never stranded on stale scores and a transient provider failure does not erase a model I was using.
|
|
174
|
+
|
|
175
|
+
A daily scan was killed by a reboot mid-run, and because systemd records a trigger when the service starts, `Persistent=true` did not retry it. The refresh now runs every 6 hours, gets a startup trigger, scans the core providers first under a wall-clock budget, keeps previous rows for providers it could not finish, and preserves the last measured score of a model that failed transiently. A PID lock keeps the 6-hour run and a retry timer from racing.
|
|
176
|
+
|
|
177
|
+
**Story ID(s) for tests:** US-39
|
|
178
|
+
**Acceptance criteria:**
|
|
179
|
+
- [ ] 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`.
|
|
180
|
+
- [ ] 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.
|
|
181
|
+
- [ ] Given a model failed transiently (timeout, rate limit, credit), When selection runs, Then its last measured score is retained and a retry is scheduled at the stated reset or 45 minutes later.
|
|
182
|
+
- [ ] 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.
|
|
183
|
+
- [ ] Given enough other providers to fill the pool, When selection runs, Then exactly one `opencode` slot is reserved last with weight 2.
|
|
184
|
+
- [ ] 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.
|
|
185
|
+
|
|
170
186
|
---
|
|
171
187
|
|
|
172
188
|
## Epic C — Hang detection and auto-recovery
|
|
@@ -531,6 +547,37 @@ A failed summarization used to clear the pending flag and never reschedule, so o
|
|
|
531
547
|
- [ ] Given the sweep runs, When it completes, Then the `event_sequence` and `session` tables are never changed.
|
|
532
548
|
- [ ] Given a run, When it completes, Then one JSON line is appended to `~/.local/share/opencode/lacode/event-prune.jsonl` carrying ts, days, graceHours, cutoff, rows, bytes, aggregates, dryRun and elapsedMs.
|
|
533
549
|
|
|
550
|
+
## Epic K — Autonomy modes (YOLO and YKB)
|
|
551
|
+
|
|
552
|
+
### US-37: YOLO mode approves permissions without stopping
|
|
553
|
+
**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.
|
|
554
|
+
|
|
555
|
+
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 is a small file the TUI writes and the server reads on the next decision, so enabling it also clears what is already pending.
|
|
556
|
+
|
|
557
|
+
**Story ID(s) for tests:** US-37
|
|
558
|
+
**Acceptance criteria:**
|
|
559
|
+
- [ ] 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.
|
|
560
|
+
- [ ] Given YOLO is off, When the runtime's permission decision is `ask`, Then it stays `ask` and still reaches the user.
|
|
561
|
+
- [ ] 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.
|
|
562
|
+
- [ ] Given YOLO is toggled in the TUI, When the mode file changes, Then the next permission decision sees the new value without a restart, and requests already pending are answered.
|
|
563
|
+
- [ ] Given `yolo: true` in the plugin options and no live mode file, When the plugin starts, Then the file is seeded; after a TUI toggle, the live file wins.
|
|
564
|
+
- [ ] Given the README and configuration reference, Then the `yolo` option and the `/yolo` command are documented alongside `/modes`.
|
|
565
|
+
|
|
566
|
+
### US-38: YKB mode chooses the recommendation instead of asking
|
|
567
|
+
**As a** user who wants the agent to keep moving, **I want** YKB (You Know Better) mode to suppress questions and pick the recommended option itself, **so that** it finishes or blocks on a real dependency rather than waiting for me to choose.
|
|
568
|
+
|
|
569
|
+
A question costs a full turn and a human decision, even when the agent has a clear recommendation. YKB adds a system instruction that tells the agent to choose and continue, and auto-answers any question that still reaches the runtime with its `(Recommended)` option (or the first option when none is marked). A free-text question with no options is left for the user, because there is nothing to choose.
|
|
570
|
+
|
|
571
|
+
**Story ID(s) for tests:** US-38
|
|
572
|
+
**Acceptance criteria:**
|
|
573
|
+
- [ ] Given YKB is on, When a request is built, Then the system prompt carries the YKB instruction exactly once; when YKB is off, the instruction is absent.
|
|
574
|
+
- [ ] Given a question offers options and YKB is on, When the plugin answers it, Then each question selects the option marked `(Recommended)` (case-insensitive, in the label or description), or the first option when none is marked.
|
|
575
|
+
- [ ] Given a question has no options, When YKB sees it, Then it leaves the question for the user and records a warning rather than inventing an answer.
|
|
576
|
+
- [ ] Given YKB is off, When a question is asked, Then the plugin sends no answer.
|
|
577
|
+
- [ ] Given YKB is toggled on in the TUI, When questions are already pending, Then they are answered with the same recommendation rule.
|
|
578
|
+
- [ ] Given YOLO and YKB are both on, When permission and question events arrive, Then each mode acts only on its own event and the other mode's behavior is unchanged.
|
|
579
|
+
- [ ] Given the README and configuration reference, Then the `ykb` option and the `/ykb` and `/modes` commands are documented.
|
|
580
|
+
|
|
534
581
|
---
|
|
535
582
|
|
|
536
583
|
## Coverage
|
|
@@ -555,6 +602,7 @@ table was filled in.
|
|
|
555
602
|
| US-7 | B | `tests/stories/b-provider-resilience.test.mjs`, `tests/pool-models-test.mjs` |
|
|
556
603
|
| US-28 | B | `tests/stories/i-omniroute.test.mjs` |
|
|
557
604
|
| US-31 | B | `tests/stories/b-provider-resilience.test.mjs`, `tests/stories/c-hang-recovery.test.mjs` |
|
|
605
|
+
| US-39 | B | `tests/stories/l-pool-refresh.test.mjs` |
|
|
558
606
|
| US-8 | C | `tests/stories/c-hang-recovery.test.mjs`, `tests/pool-hang-test.mjs` |
|
|
559
607
|
| US-9 | C | `tests/stories/c-hang-recovery.test.mjs`, `tests/pool-hang-test.mjs`, `tests/pool-shared-test.mjs` |
|
|
560
608
|
| US-10 | C | `tests/stories/c-hang-recovery.test.mjs`, `tests/pool-hang-test.mjs`, `tests/pool-timer-test.mjs` |
|
|
@@ -581,13 +629,13 @@ table was filled in.
|
|
|
581
629
|
| US-36 | G | `tests/stories/j-timeline-reliability.test.mjs` |
|
|
582
630
|
| US-32 | H | `tests/retire-old-sessions-test.mjs` |
|
|
583
631
|
| US-33 | H | `tests/prune-old-events-test.mjs` |
|
|
632
|
+
| US-37 | K | `tests/stories/k-autonomy-modes.test.mjs` |
|
|
633
|
+
| US-38 | K | `tests/stories/k-autonomy-modes.test.mjs` |
|
|
584
634
|
|
|
585
|
-
The
|
|
635
|
+
The root-level suites under `tests/` are unit-style; the `tests/stories/` suites are
|
|
586
636
|
the behavioural layer, written one epic per file with every test name carrying
|
|
587
637
|
its story id. Run everything with `npm test`.
|
|
588
638
|
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
all of epic F have tests, and the coverage guard fails the build if any of them
|
|
592
|
-
loses one. The scan covers the `tests/stories/` suites and the root-level
|
|
639
|
+
39/39 stories covered by 370 test case(s); thinnest story has 1.
|
|
640
|
+
No story is untested. The scan covers the `tests/stories/` suites and the root-level
|
|
593
641
|
`tests/*-test.mjs` suites.
|
package/package.json
CHANGED
|
@@ -14,21 +14,74 @@ export const DEFAULT_QUOTA_RETRY_MS = 15 * 60 * 1000
|
|
|
14
14
|
export const CREDIT_RETRY_MS = 6 * 60 * 60 * 1000
|
|
15
15
|
export const MAX_RETRY_DELAY_MS = 24 * 60 * 60 * 1000
|
|
16
16
|
export const STATED_COOLDOWN_MAX_MS = 6 * 60 * 60 * 1000
|
|
17
|
+
// A transient failure with no stated reset gets a bounded retry instead of
|
|
18
|
+
// vanishing from the pool until the next full scan. 45 minutes is long enough
|
|
19
|
+
// for a gateway hiccup or a short provider outage to clear, short enough that a
|
|
20
|
+
// recovered model is back well before the 6-hour timer.
|
|
21
|
+
export const SYNTHETIC_RETRY_MS = 45 * 60 * 1000
|
|
17
22
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
)
|
|
23
|
+
// Scan order, not selection order: the pool's core providers are probed first so
|
|
24
|
+
// a wall-clock budget can never starve them behind a slow paid catalogue. This is
|
|
25
|
+
// ordering only -- never an exclusion.
|
|
26
|
+
export const providerRank = (provider) => {
|
|
27
|
+
const name = String(provider ?? "")
|
|
28
|
+
if (name === "opencode-go" || name === "opencode") return 0
|
|
29
|
+
if (name === "console-openrouter" || name === "openrouter") return 2
|
|
30
|
+
return 1
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// One classifier, shared by the scorer (live probe outcome) and the selector
|
|
34
|
+
// (persisted row). The reason text is the fallback for rows written before the
|
|
35
|
+
// classifier existed or by a provider that gives only prose.
|
|
36
|
+
const FAILURE_KINDS = [
|
|
37
|
+
["agreement", /model\s+agreement|terms\s+of\s+(?:service|use)|\btos\b/i],
|
|
38
|
+
["credit", /out\s+of\s+credits|insufficient\s+credits|credit\s+balance|requires\s+more\s+credits|payment\s+required|billing|purchase|\b402\b/i],
|
|
39
|
+
["rate-limited", /rate[\s_-]?limit|usage\s+limit|too\s+many\s+requests|overloaded|limits?[\s_-]?(?:reached|exceeded)|free[-\s]?tier|freelimiterror|quota|\b429\b/i],
|
|
40
|
+
["credential", /\b401\b|unauthori[sz]ed|not\s+authori[sz]ed|invalid[\s_-]+(?:api[\s_-]?(?:key|token)|token|credential|bearer)|api[\s_-]?key\s+(?:is\s+)?(?:invalid|missing|expired|not\s+valid)|authentication\s+(?:failed|error|required)|incorrect[\s_-]?api[\s_-]?key/i],
|
|
41
|
+
["not-served", /\b404\b|no\s+such\s+model|model\s+not\s+found|does\s+not\s+exist|is\s+not\s+available|not\s+served|not\s+found/i],
|
|
42
|
+
["timeout", /timeout|timed\s+out|ETIMEDOUT|no\s+answer|no\s+response/i],
|
|
43
|
+
["network", /ECONNRESET|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|fetch\s+failed|socket\s+hang\s+up|unreachable/i],
|
|
44
|
+
["server", /\b5\d\d\b|internal\s+server|bad\s+gateway|service\s+unavailable|gateway\s+timeout/i],
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
// A credit exhaustion is transient over hours, not permanent: the balance can be
|
|
48
|
+
// topped up, and the retry schedule is what carries it. Agreement, credential
|
|
49
|
+
// and not-served failures are permanent for this model until a human acts.
|
|
50
|
+
const TRANSIENT_KINDS = new Set(["rate-limited", "credit", "timeout", "network", "server"])
|
|
51
|
+
|
|
52
|
+
export const isTransientKind = (kind) => TRANSIENT_KINDS.has(String(kind ?? "").toLowerCase())
|
|
53
|
+
|
|
54
|
+
export function classifyFailure({ status, reason, kind } = {}) {
|
|
55
|
+
if (kind) return String(kind).toLowerCase()
|
|
56
|
+
const text = String(reason ?? "")
|
|
57
|
+
const code = Number(status)
|
|
58
|
+
if (code === 402) return "credit"
|
|
59
|
+
if (code === 429) return "rate-limited"
|
|
60
|
+
if (code === 401) return "credential"
|
|
61
|
+
if (code === 403) {
|
|
62
|
+
const named = FAILURE_KINDS.find(([, re]) => re.test(text))?.[0]
|
|
63
|
+
return named === "agreement" || named === "credit" ? named : "credential"
|
|
64
|
+
}
|
|
65
|
+
if (code === 404) return "not-served"
|
|
66
|
+
if (code >= 500 && code <= 599) return "server"
|
|
67
|
+
for (const [name, re] of FAILURE_KINDS) if (re.test(text)) return name
|
|
68
|
+
return "unknown"
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const isTransientFailure = (row) => {
|
|
72
|
+
if (row?.ok !== false) return false
|
|
73
|
+
if (row.kind) return isTransientKind(row.kind)
|
|
74
|
+
return isTransientKind(classifyFailure({ status: row.status, reason: row.reason }))
|
|
75
|
+
}
|
|
23
76
|
|
|
24
77
|
const isLimitText = (text) =>
|
|
25
|
-
/rate[\s_-]?limit|usage limit|too many requests|quota|freelimiterror|overloaded|limits?[\s_-]?(?:reached|exceeded)|\b429\b/i.test(
|
|
78
|
+
/rate[\s_-]?limit|usage limit|too many requests|quota|freelimiterror|free[-\s]?tier|overloaded|limits?[\s_-]?(?:reached|exceeded)|\b429\b/i.test(
|
|
26
79
|
String(text ?? ""),
|
|
27
80
|
)
|
|
28
81
|
|
|
29
82
|
const isDailyLimit = (text) => /per[\s-]?day|daily|free-models-per-day|requests?\s+per\s+day/i.test(String(text ?? ""))
|
|
30
83
|
|
|
31
|
-
const isCreditText = /out of credits|insufficient credits|credit balance|requires more credits|\b402\b/i
|
|
84
|
+
const isCreditText = /out of credits|insufficient credits|credit balance|requires more credits|payment required|billing|purchase|\b402\b/i
|
|
32
85
|
|
|
33
86
|
const bounded = (ms) => (Number.isFinite(ms) && ms > 0 && ms <= STATED_COOLDOWN_MAX_MS ? ms : undefined)
|
|
34
87
|
|
|
@@ -81,6 +134,7 @@ export function selectPool({
|
|
|
81
134
|
minScore = MIN_SCORE,
|
|
82
135
|
poolSize = POOL_SIZE,
|
|
83
136
|
maxZen = MAX_ZEN,
|
|
137
|
+
now = Date.now,
|
|
84
138
|
} = {}) {
|
|
85
139
|
const rankable = (row) =>
|
|
86
140
|
row?.ok === true &&
|
|
@@ -89,23 +143,41 @@ export function selectPool({
|
|
|
89
143
|
row.score >= minScore &&
|
|
90
144
|
(row.context == null || row.context >= minContext)
|
|
91
145
|
const byScore = (a, b) => b.score - a.score || String(a.model).localeCompare(String(b.model))
|
|
146
|
+
const nowMs = typeof now === "function" ? Number(now()) : Number(now)
|
|
147
|
+
const normalize = (row) => {
|
|
148
|
+
if (row?.ok !== false || !isTransientFailure(row)) return row
|
|
149
|
+
const at = Number(row.retryAfter)
|
|
150
|
+
if (Number.isFinite(at) && at > nowMs) return row
|
|
151
|
+
return { ...row, retryAfter: nowMs + SYNTHETIC_RETRY_MS }
|
|
152
|
+
}
|
|
92
153
|
|
|
93
|
-
const rows = rowsOf(providers)
|
|
154
|
+
const rows = rowsOf(providers).map(normalize)
|
|
94
155
|
const previousRows = rowsOf(previousProviders)
|
|
95
156
|
const currentByModel = new Map(rows.map((row) => [row.model, row]))
|
|
157
|
+
const scannedProviders = new Set(
|
|
158
|
+
(Array.isArray(providers) ? providers : []).map((entry) => String(entry?.provider ?? "")),
|
|
159
|
+
)
|
|
96
160
|
const erroredProviders = new Set(
|
|
97
161
|
(Array.isArray(providers) ? providers : [])
|
|
98
162
|
.filter((entry) => entry?.error)
|
|
99
163
|
.map((entry) => String(entry.provider ?? "")),
|
|
100
164
|
)
|
|
165
|
+
const truncatedProviders = new Set(
|
|
166
|
+
(Array.isArray(providers) ? providers : [])
|
|
167
|
+
.filter((entry) => entry?.truncated)
|
|
168
|
+
.map((entry) => String(entry.provider ?? "")),
|
|
169
|
+
)
|
|
101
170
|
|
|
102
171
|
// A model the previous run scored is kept when this run could not reach it
|
|
103
|
-
// for a transient reason
|
|
172
|
+
// for a transient reason, when its provider was not scanned at all (a
|
|
173
|
+
// targeted scan), or when the scan budget cut that provider off. A model the
|
|
174
|
+
// provider says is gone is not kept.
|
|
104
175
|
const fallback = previousRows.filter((row) => {
|
|
105
176
|
if (!rankable(row)) return false
|
|
106
177
|
const current = currentByModel.get(row.model)
|
|
107
178
|
if (current) return isTransientFailure(current)
|
|
108
|
-
|
|
179
|
+
const provider = providerOf(row.model)
|
|
180
|
+
return !scannedProviders.has(provider) || truncatedProviders.has(provider) || erroredProviders.has(provider)
|
|
109
181
|
})
|
|
110
182
|
|
|
111
183
|
const merge = (pick) => {
|
|
@@ -146,7 +218,11 @@ export function selectPool({
|
|
|
146
218
|
const cappedSmall = capPerProvider(small, MAX_PER_PROVIDER)
|
|
147
219
|
const cappedLarge = capPerProvider(large, MAX_PER_PROVIDER_LARGE)
|
|
148
220
|
|
|
149
|
-
|
|
221
|
+
// Reserve the last slot for zen when a rankable one exists. Without the
|
|
222
|
+
// reservation a full field of other providers squeezes zen out entirely, which
|
|
223
|
+
// is the opposite of "exactly one zen slot at the end".
|
|
224
|
+
const zenReserve = zen.length > 0 ? Math.max(0, Math.min(maxZen, poolSize - go.length)) : 0
|
|
225
|
+
const remaining = Math.max(0, poolSize - go.length - zenReserve)
|
|
150
226
|
const pickedLarge = cappedLarge.slice(0, Math.min(LARGE_RESERVE, remaining))
|
|
151
227
|
const pickedSmall = cappedSmall.slice(0, Math.max(0, remaining - pickedLarge.length))
|
|
152
228
|
|
|
@@ -159,22 +235,40 @@ export function selectPool({
|
|
|
159
235
|
pickedSmall.push(...cappedSmall.slice(pickedSmall.length, pickedSmall.length + need))
|
|
160
236
|
}
|
|
161
237
|
|
|
162
|
-
const
|
|
163
|
-
const pickedZen = zen.slice(0, zenSlots)
|
|
238
|
+
const pickedZen = zen.slice(0, zenReserve)
|
|
164
239
|
|
|
165
240
|
const selection = [
|
|
166
241
|
...go.map((row) => ({ model: row.model, weight: 4, context: row.context ?? null })),
|
|
167
242
|
...pickedSmall.map((row) => ({ model: row.model, weight: 2, context: row.context ?? null })),
|
|
168
243
|
...pickedLarge.map((row) => ({ model: row.model, weight: 1, context: row.context ?? null })),
|
|
169
|
-
...pickedZen.map((row) => ({ model: row.model, weight:
|
|
244
|
+
...pickedZen.map((row) => ({ model: row.model, weight: 2, bucket: "opencode", context: row.context ?? null })),
|
|
170
245
|
]
|
|
171
246
|
|
|
172
247
|
const warnings = []
|
|
248
|
+
for (const entry of Array.isArray(providers) ? providers : []) {
|
|
249
|
+
if (entry?.error) {
|
|
250
|
+
warnings.push(`${entry.provider}: ${entry.error}`)
|
|
251
|
+
continue
|
|
252
|
+
}
|
|
253
|
+
const entryRows = Array.isArray(entry?.rows) ? entry.rows : []
|
|
254
|
+
if (entryRows.length === 0 || entryRows.some(rankable)) continue
|
|
255
|
+
const kinds = new Map()
|
|
256
|
+
for (const row of entryRows) {
|
|
257
|
+
if (row?.ok !== false) continue
|
|
258
|
+
const kind = row.kind ?? classifyFailure({ status: row.status, reason: row.reason })
|
|
259
|
+
kinds.set(kind, (kinds.get(kind) ?? 0) + 1)
|
|
260
|
+
}
|
|
261
|
+
const [dominant, count] = [...kinds.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0] ?? [
|
|
262
|
+
"unknown",
|
|
263
|
+
0,
|
|
264
|
+
]
|
|
265
|
+
warnings.push(`${entry.provider}: ${entryRows.length} probed, 0 rankable — dominant failure: ${dominant} (${count})`)
|
|
266
|
+
}
|
|
173
267
|
const total = selection.length
|
|
174
268
|
if (total === 0) warnings.push("no scorable model at or above the minimum score is available")
|
|
175
269
|
else if (total < poolSize) warnings.push(`only ${total} scorable model(s) available, wanted ${poolSize}`)
|
|
176
270
|
|
|
177
|
-
const limited = rows.filter((row) => row?.ok === false && Number.isFinite(row.retryAfter))
|
|
271
|
+
const limited = rows.filter((row) => row?.ok === false && isTransientFailure(row) && Number.isFinite(row.retryAfter))
|
|
178
272
|
const retry =
|
|
179
273
|
limited.length > 0
|
|
180
274
|
? {
|
package/scripts/refresh-pool.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
//
|
|
2
|
+
// Refreshable pool builder; the installed systemd timer runs it every 6 hours.
|
|
3
3
|
//
|
|
4
4
|
// Reads the scorer's output (~/.config/lacode/model-scores.json) and proposes a
|
|
5
5
|
// rewritten ~/.config/lacode/pool.txt. It never partially writes and never
|
|
@@ -11,13 +11,14 @@
|
|
|
11
11
|
// descending, weight 4
|
|
12
12
|
// 2. the best (10 - n) models from every provider other than opencode,
|
|
13
13
|
// sorted by score descending, weight 2
|
|
14
|
-
// 3.
|
|
14
|
+
// 3. exactly one opencode model as the reserved last slot, weight 2, with
|
|
15
|
+
// b:opencode, when a rankable opencode model exists
|
|
15
16
|
//
|
|
16
|
-
// Total = n +
|
|
17
|
+
// Total = n + (others up to 10 - n - zen) + zen, where n = #1.
|
|
17
18
|
//
|
|
18
19
|
// This file ships as source and must run with plain `node` (ESM, no TS).
|
|
19
20
|
|
|
20
|
-
import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"
|
|
21
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"
|
|
21
22
|
import { execFileSync } from "node:child_process"
|
|
22
23
|
import { homedir, userInfo } from "node:os"
|
|
23
24
|
import { dirname, join, resolve } from "node:path"
|
|
@@ -34,6 +35,51 @@ export const STABLE_SELECTION = (home = HOME) => join(STABLE_LIB_DIR(home), "poo
|
|
|
34
35
|
export const REFRESH_SERVICE_NAME = "lacode-refresh-pool.service"
|
|
35
36
|
export const REFRESH_TIMER_NAME = "lacode-refresh-pool.timer"
|
|
36
37
|
|
|
38
|
+
const REFRESH_LOCK_DIR = join(HOME, ".config", "lacode", "refresh.lock")
|
|
39
|
+
const REFRESH_LOCK_STALE_MS = 60 * 60 * 1000
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* One refresh at a time. The 6-hourly service and a transient retry timer can
|
|
43
|
+
* otherwise overlap and race on model-scores.json. A killed run leaves the lock
|
|
44
|
+
* behind, so a live PID keeps it only for an hour; a dead PID or a stale lock is
|
|
45
|
+
* reclaimed by the next run.
|
|
46
|
+
*/
|
|
47
|
+
function acquireRefreshLock() {
|
|
48
|
+
const take = () => {
|
|
49
|
+
mkdirSync(REFRESH_LOCK_DIR)
|
|
50
|
+
writeFileSync(join(REFRESH_LOCK_DIR, "pid"), String(process.pid))
|
|
51
|
+
return true
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
return take()
|
|
55
|
+
} catch {}
|
|
56
|
+
try {
|
|
57
|
+
const stat = statSync(REFRESH_LOCK_DIR)
|
|
58
|
+
let pid = 0
|
|
59
|
+
try {
|
|
60
|
+
pid = Number(readFileSync(join(REFRESH_LOCK_DIR, "pid"), "utf8"))
|
|
61
|
+
} catch {}
|
|
62
|
+
let alive = false
|
|
63
|
+
if (Number.isFinite(pid) && pid > 0) {
|
|
64
|
+
try {
|
|
65
|
+
process.kill(pid, 0)
|
|
66
|
+
alive = true
|
|
67
|
+
} catch {}
|
|
68
|
+
}
|
|
69
|
+
if (alive && Date.now() - stat.mtimeMs < REFRESH_LOCK_STALE_MS) return false
|
|
70
|
+
rmSync(REFRESH_LOCK_DIR, { recursive: true, force: true })
|
|
71
|
+
return take()
|
|
72
|
+
} catch {
|
|
73
|
+
return false
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function releaseRefreshLock() {
|
|
78
|
+
try {
|
|
79
|
+
rmSync(REFRESH_LOCK_DIR, { recursive: true, force: true })
|
|
80
|
+
} catch {}
|
|
81
|
+
}
|
|
82
|
+
|
|
37
83
|
const isSlotLine = (line) => {
|
|
38
84
|
const stripped = String(line).split("#")[0].trim()
|
|
39
85
|
return stripped.length > 0 && stripped.includes("/")
|
|
@@ -191,15 +237,21 @@ Wants=network-online.target
|
|
|
191
237
|
|
|
192
238
|
[Service]
|
|
193
239
|
Type=oneshot
|
|
240
|
+
TimeoutStartSec=infinity
|
|
194
241
|
ExecStart=${nodePath} ${scriptPath}
|
|
195
242
|
Environment=PATH=${home}/.opencode/bin:/usr/local/bin:/usr/bin:/bin
|
|
196
243
|
Nice=10
|
|
197
244
|
`
|
|
198
245
|
const timer = `[Unit]
|
|
199
|
-
Description=
|
|
246
|
+
Description=LaCode model pool refresh every 6 hours
|
|
200
247
|
|
|
201
248
|
[Timer]
|
|
202
|
-
OnCalendar=*-*-*
|
|
249
|
+
OnCalendar=*-*-* 00/6:00:00
|
|
250
|
+
# A reboot kills a run mid-scan. Persistent=true does NOT retry that, because
|
|
251
|
+
# systemd records the trigger when the service starts, not when it succeeds, so
|
|
252
|
+
# the 6-hour slot is already spent even though the run never finished. A startup
|
|
253
|
+
# trigger gives the next boot a fresh run even if the previous one was killed.
|
|
254
|
+
OnStartupSec=5min
|
|
203
255
|
Persistent=true
|
|
204
256
|
Unit=${REFRESH_SERVICE_NAME}
|
|
205
257
|
|
|
@@ -243,6 +295,7 @@ function installTimerUnits(stable = false) {
|
|
|
243
295
|
console.log(`installed ${units.servicePath}`)
|
|
244
296
|
console.log(`installed ${units.timerPath}`)
|
|
245
297
|
console.log(`enabled ${REFRESH_TIMER_NAME}`)
|
|
298
|
+
console.log(`refresh now: systemctl --user start ${REFRESH_SERVICE_NAME}`)
|
|
246
299
|
return true
|
|
247
300
|
}
|
|
248
301
|
|
|
@@ -251,9 +304,11 @@ function usage() {
|
|
|
251
304
|
|
|
252
305
|
--from-scores skip scoring, use the existing ~/.config/lacode/model-scores.json
|
|
253
306
|
--no-retry do not schedule a transient retry after a rate-limited run
|
|
254
|
-
--install-timer install and enable a user systemd oneshot service +
|
|
255
|
-
(add --stable to copy the script to ~/.local/lib/lacode
|
|
256
|
-
point ExecStart there, so plugin-cache updates do not break
|
|
307
|
+
--install-timer install and enable a user systemd oneshot service + 6-hourly
|
|
308
|
+
timer (add --stable to copy the script to ~/.local/lib/lacode
|
|
309
|
+
and point ExecStart there, so plugin-cache updates do not break
|
|
310
|
+
it). Refresh on demand with:
|
|
311
|
+
systemctl --user start lacode-refresh-pool.service
|
|
257
312
|
--print print the proposed pool.txt and make no changes (alias: --dry-run)
|
|
258
313
|
--dry-run same as --print
|
|
259
314
|
--max N models per provider passed to the scorer (default: 50)
|
|
@@ -281,6 +336,14 @@ async function main() {
|
|
|
281
336
|
const scoresPath = value("--scores", DEFAULT_SCORES_PATH)
|
|
282
337
|
const poolPath = value("--pool", DEFAULT_POOL_PATH)
|
|
283
338
|
|
|
339
|
+
if (!printOnly) {
|
|
340
|
+
if (!acquireRefreshLock()) {
|
|
341
|
+
console.log("another LaCode pool refresh is already running; skipping this run")
|
|
342
|
+
return
|
|
343
|
+
}
|
|
344
|
+
process.once("exit", releaseRefreshLock)
|
|
345
|
+
}
|
|
346
|
+
|
|
284
347
|
if (!fromScores) {
|
|
285
348
|
try {
|
|
286
349
|
runScorer(max)
|