@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.
@@ -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 and the spawn is never refused outright.
92
- - [ ] Given every model is cooling, When a spawn is routed, Then the least-loaded slot is chosen anyway (degrade, never block).
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 nine suites under `tests/` are unit-style; the `tests/stories/` suites are
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
- **No story is uncovered.** The guard reports 36 stories, 348 test cases, and
590
- 36/36 stories with at least one behavioural test; US-1, US-2, US-3, US-6, and
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lawaty/lacode",
3
- "version": "2.2.0-beta.0",
3
+ "version": "2.3.0-beta.0",
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",
@@ -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
- export const isTransientFailure = (row) =>
19
- row?.ok === false &&
20
- /timeout|timed out|rate-limit|no answer|no response|unreachable|ECONN|ETIMEDOUT|fetch failed|socket hang up|credit|402/i.test(
21
- String(row.reason ?? ""),
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. A model the provider says is gone is not kept.
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
- return erroredProviders.has(providerOf(row.model))
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
- const remaining = Math.max(0, poolSize - go.length)
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 zenSlots = Math.max(0, Math.min(maxZen, poolSize - go.length - pickedSmall.length - pickedLarge.length))
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: 1, bucket: "opencode", context: row.context ?? null })),
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
  ? {
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // Daily-refreshable pool builder.
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. at most one opencode model as a last resort, weight 2, with b:opencode
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 + min(10 - n, #others), where n = #1.
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=Daily LaCode model pool refresh
246
+ Description=LaCode model pool refresh every 6 hours
200
247
 
201
248
  [Timer]
202
- OnCalendar=*-*-* 01:00:00
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 + daily timer
255
- (add --stable to copy the script to ~/.local/lib/lacode and
256
- point ExecStart there, so plugin-cache updates do not break it)
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)