@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/CHANGELOG.md +128 -1
- package/README.md +19 -2
- package/dist/agents.js +30 -3
- package/dist/commands.js +4 -2
- package/dist/index.js +491 -36
- package/dist/pool.js +149 -8
- package/docs/OMNIROUTE.md +17 -15
- package/docs/USER-STORIES.md +2 -2
- package/package.json +1 -1
- package/presets/README.md +4 -4
- package/presets/free-tier.json +3 -2
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
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
29
|
-
|
|
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.
|
|
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,
|
|
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/
|
|
104
|
-
opencode/space-bunny-free
|
|
105
|
-
opencode-go/longcat-2.5-preview-free
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
the
|
|
114
|
-
|
|
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
|
|
package/docs/USER-STORIES.md
CHANGED
|
@@ -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
|
|
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
|
|
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
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 |
|
|
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
|
|
95
|
-
|
|
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
|
package/presets/free-tier.json
CHANGED
|
@@ -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
|
|
17
|
-
"
|
|
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:",
|