@lawaty/lacode 0.7.1 → 0.8.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/dist/pool.js CHANGED
@@ -34,12 +34,15 @@ var MAX_CLAIM_AGE_MS = 30 * 60 * 1e3;
34
34
  var HANG_COOLDOWN_MS = 10 * 60 * 1e3;
35
35
  var UNAVAILABLE_COOLDOWN_MS = 60 * 1e3;
36
36
  var MAX_WEIGHT = 100;
37
+ var SLOT_CEILING_FACTOR = 2;
38
+ var slotCeiling = (slot) => slot.weight * SLOT_CEILING_FACTOR;
37
39
  var MAX_SLOTS = 4;
38
40
  var COOLDOWN_BASE_MS = 60 * 1e3;
39
41
  var COOLDOWN_MAX_MS = 15 * 60 * 1e3;
40
42
  var QUOTA_COOLDOWN_BASE_MS = 15 * 60 * 1e3;
41
43
  var QUOTA_COOLDOWN_MAX_MS = 6 * 60 * 60 * 1e3;
42
44
  var STRIKE_DECAY_MS = 30 * 60 * 1e3;
45
+ var STATED_COOLDOWN_MAX_MS = QUOTA_COOLDOWN_MAX_MS;
43
46
  var isQuotaReason = (reason) => /quota|limit|rate|too-many|overloaded|429/i.test(reason);
44
47
  var BASES = ["explore", "implement", "context"];
45
48
  var PRESETS_DIR = join2(PACKAGE_ROOT, "presets");
@@ -207,12 +210,13 @@ var isUnavailableText = (text) => UNAVAILABLE_PATTERN.test(text);
207
210
  var TRANSIENT_GATEWAY_PATTERNS = [/queue budget/i, /exceeded queue/i, /aborted due to timeout/i, /timeout/i];
208
211
  var isTransientGatewayText = (text) => TRANSIENT_GATEWAY_PATTERNS.some((re) => re.test(text));
209
212
  var statedCooldown = (text) => {
210
- const hm = /resets?\s+in\s+(\d+)\s*(?:h|hr|hour)s?\s*(\d+)?\s*(?:m|min|minute)?s?/i.exec(text);
211
- if (hm) return (Number(hm[1]) * 60 + (hm[2] ? Number(hm[2]) : 0)) * 60 * 1e3;
212
- const m = /resets?\s+in\s+(\d+)\s*(?:m|min|minute)s?/i.exec(text);
213
- if (m) return Number(m[1]) * 60 * 1e3;
214
- const s = /retry[\s-]?in\s+(\d+)\s*(?:s|sec|second)s?/i.exec(text);
215
- if (s) return Number(s[1]) * 1e3;
213
+ const bounded = (ms) => Number.isFinite(ms) && ms > 0 && ms <= STATED_COOLDOWN_MAX_MS ? ms : void 0;
214
+ const hm = /resets?\s+in\s+(\d+)\s*(?:hour|hr|h)s?\s*(\d+)?\s*(?:minute|min|m)?s?/i.exec(text);
215
+ if (hm) return bounded((Number(hm[1]) * 60 + (hm[2] ? Number(hm[2]) : 0)) * 60 * 1e3);
216
+ const m = /resets?\s+in\s+(\d+)\s*(?:minute|min|m)s?/i.exec(text);
217
+ if (m) return bounded(Number(m[1]) * 60 * 1e3);
218
+ const s = /retry[\s-]?in\s+(\d+)\s*(?:second|sec|s)s?/i.exec(text);
219
+ if (s) return bounded(Number(s[1]) * 1e3);
216
220
  return void 0;
217
221
  };
218
222
  var retryAfterMs = (headers) => {
@@ -246,10 +250,14 @@ function state(opts = {}) {
246
250
  cooling: /* @__PURE__ */ new Map(),
247
251
  strikes: /* @__PURE__ */ new Map(),
248
252
  activity: /* @__PURE__ */ new Map(),
253
+ probes: /* @__PURE__ */ new Set(),
254
+ probing: false,
255
+ lastProbe: 0,
249
256
  children: /* @__PURE__ */ new Map(),
250
257
  reaped: 0,
251
258
  reaping: false,
252
259
  lastSweep: 0,
260
+ lastDirSweep: 0,
253
261
  id,
254
262
  dir,
255
263
  modelsFile,
@@ -286,10 +294,88 @@ function init(s, onWarn) {
286
294
  onWarn(`claim dir ${s.dir} unusable; pool load tracked in memory only`);
287
295
  }
288
296
  }
297
+ var STATE_SWEEP_INTERVAL_MS = 60 * 60 * 1e3;
298
+ function sweepStateDir(s) {
299
+ const removed = [];
300
+ if (!s.dirReady) return removed;
301
+ let entries;
302
+ try {
303
+ entries = readdirSync(s.dir);
304
+ } catch {
305
+ return removed;
306
+ }
307
+ const now = Date.now();
308
+ const drop = (name) => {
309
+ try {
310
+ unlinkSync(join2(s.dir, name));
311
+ removed.push(name);
312
+ } catch {
313
+ }
314
+ };
315
+ for (const entry of entries) {
316
+ let age;
317
+ try {
318
+ age = now - statSync(join2(s.dir, entry)).mtimeMs;
319
+ } catch {
320
+ continue;
321
+ }
322
+ if (entry.endsWith(".tmp")) {
323
+ if (age > ORPHAN_TMP_MS) drop(entry);
324
+ continue;
325
+ }
326
+ const claim = /^claims\.(.+)\.json$/.exec(entry);
327
+ if (claim) {
328
+ if (claim[1] === s.id) continue;
329
+ if (/^\d+$/.test(claim[1]) && !isPidAlive(Number(claim[1]))) {
330
+ drop(entry);
331
+ continue;
332
+ }
333
+ if (age > DEAD_FILE_MS) drop(entry);
334
+ continue;
335
+ }
336
+ if (entry.startsWith("limit.") && entry.endsWith(".json")) {
337
+ try {
338
+ const strike = JSON.parse(readFileSync2(join2(s.dir, entry), "utf8"));
339
+ if (typeof strike.until !== "number") {
340
+ drop(entry);
341
+ continue;
342
+ }
343
+ if (strike.until > now) continue;
344
+ if (typeof strike.last === "number" && now - strike.last < STRIKE_DECAY_MS) continue;
345
+ drop(entry);
346
+ } catch {
347
+ drop(entry);
348
+ }
349
+ }
350
+ }
351
+ return removed;
352
+ }
289
353
  function pruneOwn(s) {
290
354
  const now = Date.now();
291
355
  for (const [key, claim] of s.own) if (now - (claim.hb ?? claim.t) > CLAIM_TTL_MS) s.own.delete(key);
292
356
  }
357
+ function pruneActivity(s, now) {
358
+ if (s.activity.size === 0) return;
359
+ const live = /* @__PURE__ */ new Set();
360
+ for (const claim of s.own.values()) if (claim.g) live.add(claim.g);
361
+ for (const [id, seen] of s.activity) {
362
+ if (live.has(id)) continue;
363
+ if (now - seen > CLAIM_TTL_MS) s.activity.delete(id);
364
+ }
365
+ }
366
+ function pruneChildren(s, now) {
367
+ if (s.children.size === 0) return;
368
+ for (const [parent, ring] of s.children) {
369
+ const fresh = ring.filter((child) => now - child.t <= CHILD_MEMORY_MS);
370
+ if (fresh.length === 0) s.children.delete(parent);
371
+ else if (fresh.length !== ring.length) s.children.set(parent, fresh);
372
+ }
373
+ }
374
+ function pruneSessions(s) {
375
+ const now = Date.now();
376
+ pruneActivity(s, now);
377
+ pruneChildren(s, now);
378
+ }
293
379
  function publish(s) {
294
380
  if (!s.dirReady) return;
295
381
  const now = Date.now();
@@ -435,6 +521,50 @@ function coolUnavailable(s, model) {
435
521
  }
436
522
  return strike;
437
523
  }
524
+ function clearCooldowns(s, models) {
525
+ const cleared = [];
526
+ const now = Date.now();
527
+ const wanted = models && models.length > 0 ? new Set(models) : void 0;
528
+ let entries;
529
+ try {
530
+ entries = readdirSync(s.dir);
531
+ } catch {
532
+ return cleared;
533
+ }
534
+ for (const entry of entries) {
535
+ if (!entry.startsWith("limit.") || !entry.endsWith(".json")) continue;
536
+ const path = join2(s.dir, entry);
537
+ let strike;
538
+ try {
539
+ const parsed = JSON.parse(readFileSync2(path, "utf8"));
540
+ if (parsed && typeof parsed.model === "string") strike = parsed;
541
+ } catch {
542
+ strike = void 0;
543
+ }
544
+ if (wanted && (strike === void 0 || !wanted.has(strike.model))) continue;
545
+ try {
546
+ unlinkSync(path);
547
+ } catch {
548
+ continue;
549
+ }
550
+ if (strike === void 0) {
551
+ cleared.push({ model: entry, reason: "unreadable cooldown file", strikes: 0, secondsLeft: 0 });
552
+ continue;
553
+ }
554
+ s.cooling.delete(strike.model);
555
+ s.strikes.delete(strike.model);
556
+ cleared.push({
557
+ model: strike.model,
558
+ reason: typeof strike.reason === "string" ? strike.reason : "unknown",
559
+ strikes: typeof strike.strikes === "number" ? strike.strikes : 0,
560
+ secondsLeft: typeof strike.until === "number" ? Math.max(0, Math.ceil((strike.until - now) / 1e3)) : 0
561
+ });
562
+ }
563
+ return cleared;
564
+ }
565
+ var markProbe = (s, sessionID) => s.probes.add(sessionID);
566
+ var unmarkProbe = (s, sessionID) => s.probes.delete(sessionID);
567
+ var isProbe = (s, sessionID) => s.probes.has(sessionID);
438
568
  function refreshCatalog(s, serverUrl) {
439
569
  if (!serverUrl || Date.now() - s.catalogAt < CATALOG_TTL_MS) return;
440
570
  s.catalogAt = Date.now();
@@ -457,6 +587,7 @@ function isAvailable(s, model, cooling) {
457
587
  }
458
588
  function snapshot(s) {
459
589
  pruneOwn(s);
590
+ pruneSessions(s);
460
591
  const siblings = readSiblings(s);
461
592
  const cooling = readLimits(s);
462
593
  const counts = /* @__PURE__ */ new Map();
@@ -466,7 +597,7 @@ function snapshot(s) {
466
597
  return { counts, cooling, siblings, own: s.own, dirReady: s.dirReady };
467
598
  }
468
599
  var loadOf = (counts, model) => counts.get(model) ?? 0;
469
- var underCeiling = (slot, counts) => loadOf(counts, slot.model) < slot.weight;
600
+ var underCeiling = (slot, counts) => loadOf(counts, slot.model) < slotCeiling(slot);
470
601
  function roundRobinSequence(slots) {
471
602
  const seq = [];
472
603
  for (const slot of slots) for (let turn = 0; turn < slot.weight; turn++) seq.push(slot.index);
@@ -629,6 +760,9 @@ export {
629
760
  PRESETS_DIR,
630
761
  QUOTA_COOLDOWN_BASE_MS,
631
762
  QUOTA_COOLDOWN_MAX_MS,
763
+ SLOT_CEILING_FACTOR,
764
+ STATED_COOLDOWN_MAX_MS,
765
+ STATE_SWEEP_INTERVAL_MS,
632
766
  STUCK_IDLE_MS,
633
767
  STUCK_MIN_AGE_MS,
634
768
  UNAVAILABLE_COOLDOWN_MS,
@@ -640,6 +774,7 @@ export {
640
774
  backfillChild,
641
775
  bindChild,
642
776
  claimKey,
777
+ clearCooldowns,
643
778
  condemnReason,
644
779
  coolProvider,
645
780
  coolUnavailable,
@@ -650,10 +785,12 @@ export {
650
785
  init,
651
786
  isAvailable,
652
787
  isLimitText,
788
+ isProbe,
653
789
  isQuotaReason,
654
790
  isTransientGatewayText,
655
791
  isUnavailableText,
656
792
  manualVariant,
793
+ markProbe,
657
794
  noteActivity,
658
795
  parseSlotText,
659
796
  parseSlots,
@@ -661,6 +798,7 @@ export {
661
798
  pickSlot,
662
799
  providerOf,
663
800
  pruneOwn,
801
+ pruneSessions,
664
802
  publish,
665
803
  readLimits,
666
804
  readSiblings,
@@ -673,10 +811,13 @@ export {
673
811
  retryAfterMs,
674
812
  roundRobinSequence,
675
813
  selfFile,
814
+ slotCeiling,
676
815
  slotFor,
677
816
  snapshot,
678
817
  splitModel,
679
818
  state,
680
819
  statedCooldown,
681
- underCeiling
820
+ sweepStateDir,
821
+ underCeiling,
822
+ unmarkProbe
682
823
  };
package/docs/OMNIROUTE.md CHANGED
@@ -25,8 +25,10 @@ as an opencode provider for you. It is **off by default**: if you do not set
25
25
  `auto` is the zero-config default, but on a free tier it often resolves to a
26
26
  slow chain. [A tested fast combo](#a-tested-fast-combo-free-mix) below is
27
27
  ordered for latency and is worth applying on a new client.
28
- 3. Optionally, to route pooled work through it, make `omniroute/auto 3` the
29
- **first** line of `~/.config/lacode/pool.txt`. `pool.txt` is optional user
28
+ 3. Optionally, to route pooled work through it, add an `omniroute/<id>` slot to
29
+ `~/.config/lacode/pool.txt` exactly as you would any other provider. The
30
+ integration gives it no special precedence, priority or weight — its place and
31
+ weight in the pool are whatever you configure. `pool.txt` is optional user
30
32
  config; the bundled default preset does **not** include OmniRoute.
31
33
  4. **Restart opencode.** Nothing here hot-reloads.
32
34
 
@@ -64,7 +66,7 @@ The full option set, for reference:
64
66
  | `enabled` | `false` | Turns the integration on. |
65
67
  | `baseURL` | `http://localhost:20128/v1` | Gateway base URL, normalised to exactly one `/v1`. |
66
68
  | `apiKey` | — | Bearer key. **`OMNIROUTE_API_KEY` overrides this value.** The key is never logged. |
67
- | `combo` | — | An OmniRoute combo id (or `auto`) to expose as a selectable model even though the gateway does not list it. Exposed first. |
69
+ | `combo` | — | An OmniRoute combo id (or `auto`) to expose as a selectable model even though the gateway does not list it. It leads the provider's own model list — an OmniRoute-side detail, unrelated to pool order. |
68
70
  | `models` | — | A **hard allowlist**: only these ids are ever exposed, even if the gateway lists more. |
69
71
  | `tiers` | — | `{ "primary": [...], "backup": [...] }` preference order over models the allowlist already permits. |
70
72
  | `stickyPerSession` | `true` | Keep a session on one model while that model is healthy. |
@@ -95,23 +97,23 @@ failed.
95
97
 
96
98
  **Using OmniRoute in the pool.** The integration only *registers* the provider —
97
99
  it never touches your pool, and it does not place a slot for you. To route pooled
98
- work (exploration, implementation, cartography) through the gateway, list it in
99
- your pool like any other model:
100
+ work (exploration, implementation, cartography) through the gateway, add an
101
+ `omniroute/<id>` slot to your pool like any other model:
100
102
 
101
103
  ```
102
104
  # ~/.config/lacode/pool.txt
103
- omniroute/auto 3
104
- opencode/space-bunny-free 2
105
- opencode-go/longcat-2.5-preview-free 1
106
- opencode/big-pickle 1
105
+ omniroute/free-mix
106
+ opencode/space-bunny-free
107
+ opencode-go/longcat-2.5-preview-free
107
108
  ```
108
109
 
109
- `omniroute/auto` then takes slot 1 (weight 3) and drains pooled mechanical work
110
- before it reaches the bundled opencode/opencode-go ("zen") models. Any
111
- `omniroute/<id>` works, provided the id is exposed — by `combo`/`models`, or the
112
- whole catalog when neither is set. Because the plugin writes the pool nothing,
113
- the only thing that changes when the gateway is down is that `omniroute/<id>`
114
- fails; every other slot keeps working. See
110
+ OmniRoute is a provider like any other: the pool decides its place and weight,
111
+ and neither the pool nor this integration gives it any built-in precedence or
112
+ priority — where it sits and how many turns it takes are whatever you configure.
113
+ Any `omniroute/<id>` works, provided the id is exposed — by `combo`/`models`, or
114
+ the whole catalog when neither is set. Because the plugin writes the pool
115
+ nothing, the only thing that changes when the gateway is down is that
116
+ `omniroute/<id>` fails; every other slot keeps working. See
115
117
  [docs/USER-STORIES.md](USER-STORIES.md) US-28.
116
118
 
117
119
 
@@ -102,10 +102,10 @@ The reconciliation with US-7 is that **acceptance and endorsement are different
102
102
  **Story ID(s) for tests:** US-5
103
103
  **Acceptance criteria:**
104
104
  - [ ] Given a heavy slot and a light slot, When spawns are routed below both ceilings, Then the rotation walks the list in order with each slot taking as many consecutive turns as its weight says, rather than the heavy slot being filled and refilled first.
105
- - [ ] Given a slot is at its weight ceiling or cooling, When its turn comes up in the rotation, Then the turn is skipped and the rotation resumes at the following turn.
105
+ - [ ] Given a slot is at its ceiling (twice its weight) or cooling, When its turn comes up in the rotation, Then the turn is skipped and the rotation resumes at the following turn.
106
106
  - [ ] Given every slot is at its ceiling, When another spawn is routed, Then it goes to the least-loaded slot as overflow.
107
107
  - [ ] Given all slots carry equal weight, When spawns are routed, Then the picks round-robin in declaration order.
108
- - [ ] Given per-slot load is read, When it is asserted, Then a slot's load never exceeds its weight ceiling except by overflow.
108
+ - [ ] Given per-slot load is read, When it is asserted, Then a slot's load never exceeds its ceiling (twice its weight) except by overflow.
109
109
 
110
110
  ### US-6: Live pool visibility
111
111
  **As a** user, **I want** to see per-slot load and which model is cooling and why, **so that** I understand where my subagents are going.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lawaty/lacode",
3
- "version": "0.7.1",
3
+ "version": "0.8.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",
package/presets/README.md CHANGED
@@ -62,7 +62,7 @@ else means the flat form.
62
62
  | Field | Required | Meaning |
63
63
  |-------|----------|---------|
64
64
  | `model` | yes | `provider/model-id`, split on the **first** slash only |
65
- | `weight` | no | soft ceiling on concurrent claims, and turns taken per rotation. Absent = 1 |
65
+ | `weight` | no | turns taken per rotation (the slot's share of the rotation); its concurrency ceiling is twice that. Absent = 1 |
66
66
 
67
67
  `$comment` is a JSON-legal array of strings and is ignored by the loader; the
68
68
  bundled preset uses it to explain itself in place.
@@ -91,9 +91,9 @@ because the pool requires it.
91
91
 
92
92
  ## Weight, precisely
93
93
 
94
- A weight is a count of concurrent sessions — a soft ceiling — and the number
95
- of turns a slot takes per rotation. Routing is **order-first weighted
96
- round-robin**:
94
+ A weight is the number of turns a slot takes per rotation — its share of the
95
+ work — and its concurrency ceiling is **twice that weight**: a weight-3 slot may
96
+ hold 6 concurrent sessions. Routing is **order-first weighted round-robin**:
97
97
 
98
98
  1. the slot list is expanded into a run in which each slot appears once per
99
99
  unit of its weight — `[3,2,1,1]` becomes `1,1,1,2,2,3,4` — and the next
@@ -13,8 +13,9 @@
13
13
  "AT MOST 4 SLOTS: one hidden variant per base per slot is declared, so a fifth entry would",
14
14
  "route at an agent that does not exist. Extra entries are dropped with a warning.",
15
15
  "",
16
- "WEIGHT IS THE ONLY KNOWN KNOB, and it is a SOFT CEILING on how many concurrent claims a",
17
- "slot holds -- not a share of the work and not a priority. Routing is ORDER-FIRST",
16
+ "WEIGHT IS THE ONLY KNOWN KNOB: one position per unit of weight in the rotation run,",
17
+ "and a concurrency ceiling of TWICE that weight -- a share of the work, not a priority.",
18
+ "Routing is ORDER-FIRST",
18
19
  "WEIGHTED ROUND-ROBIN: the slot list is walked IN ORDER and each slot takes as many",
19
20
  "consecutive turns as its weight says, so weights 3,2,1,1 produce the run 1,1,1,2,2,3,4,",
20
21
  "which then repeats. The rule is:",