@lawaty/lacode 0.3.0 → 0.4.1

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 ADDED
@@ -0,0 +1,168 @@
1
+ # Changelog
2
+
3
+ All notable changes to LaCode are recorded here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and releases follow
5
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ Versions come from git release commits; the npm version is bumped in the same
8
+ commit as the changelog entry it describes.
9
+
10
+ ## [Unreleased]
11
+
12
+ ## [0.4.1] — refuse hand-named slot variants, and ship a changelog
13
+
14
+ ### Added
15
+
16
+ - `CHANGELOG.md`, reconstructing all 13 published releases from their commits. It
17
+ ships with the package, so an install can read what changed without the repo.
18
+
19
+ ### Changed
20
+
21
+ - A pool slot variant named by hand (`explore-1`, `implement-2`, …) is now
22
+ **refused** rather than counted. Naming one skipped both the round-robin
23
+ rotation and the slot's ceiling, so six spawns of `explore-1` put six
24
+ concurrent claims on a slot bounded at three. The refusal names the bare base
25
+ to spawn instead, is logged, and escapes the routing catch — swallowed into
26
+ "spawn unchanged" it would have admitted the spawn it forbids.
27
+ - Agent prompts dropped the long explanation of why a variant must not be named.
28
+ It is enforced now, and the refusal carries the reason; the prompts keep only
29
+ the bare-base rule and what to do on refusal. 1,012 characters removed across
30
+ the `build`, `plan` and `explore` prompts.
31
+
32
+ ### Documented
33
+
34
+ - `isAvailable` and `pickSlot` now say, in place, that the pool is deliberately
35
+ fail-open in two places: a model absent from the catalog is not treated as
36
+ unavailable, and when every slot is cooling they are re-admitted rather than
37
+ the work refused. Both were previously only discoverable by reading the code.
38
+
39
+ ## [0.4.0] — order-first weighted round-robin, and a reaper that can kill
40
+
41
+ ### Changed
42
+
43
+ - **Slot selection is now order-first weighted round-robin.** Weight was both
44
+ the priority and the ceiling, so the heaviest slot was re-prioritised on every
45
+ spawn and refilled the moment a claim completed, while the others sat idle.
46
+ Selection walks the slot list in order, each slot taking as many turns as its
47
+ weight says, from a cursor persisted in the pool directory so the rotation is
48
+ shared by every opencode process rather than restarting in each one. Weights
49
+ `[3,2,1,1]` now produce the run `1,1,1,2,2,3,4`.
50
+ - The concurrency ceiling is unchanged: a slot still holds at most `weight`
51
+ concurrent claims, and overflow still goes to the least-loaded slot.
52
+ - `pool_status` previews the next slot without advancing the rotation, so
53
+ looking at the pool no longer shifts every spawn after it.
54
+
55
+ ### Fixed
56
+
57
+ - **A hung subagent could not be killed.** The reaper detected the wedge, freed
58
+ the claim, and then failed to abort the session — because the claim was
59
+ released *before* the abort, so a failed kill orphaned the session with
60
+ nothing left to retry it. Kills are verified against the response, retried
61
+ three times, and only a confirmed kill releases the claim; a give-up names
62
+ the session to kill by hand.
63
+ - A kill reported success for any HTTP response, including the gateway's
64
+ `200` with body `true` for a session that does not exist.
65
+ - A claim that never bound to a child session was logged as `Infinitys of
66
+ silence` and cooled the provider, though an unbound claim is no evidence that
67
+ anything hung. It is now released without cooling, and a child created before
68
+ its claim is bound at reap time instead of being lost.
69
+ - **A live task lost its slot past the TTL.** Claims now carry a heartbeat
70
+ refreshed by message activity, and abandonment is measured from it. A task
71
+ that ran past the 10-minute TTL previously stopped being counted while it
72
+ kept working, so the slot was handed out again — a ceiling-3 slot ran five.
73
+
74
+ ## [0.3.0] — a sound per notification outcome (US-29)
75
+
76
+ ### Changed
77
+
78
+ - Notification outcomes are per-notification and sound, rather than one silent
79
+ aggregate.
80
+
81
+ ## [0.2.2] — stop capping gateway traffic at 5s
82
+
83
+ ### Fixed
84
+
85
+ - `timeoutMs` no longer caps real provider traffic at 5s. It was documented as
86
+ a probe budget only, but it was being applied to live completions too.
87
+
88
+ ## [0.2.1] — OmniRoute registers the provider only
89
+
90
+ ### Changed
91
+
92
+ - The OmniRoute integration registers the provider and nothing else; it writes
93
+ no pool file and no variants.
94
+
95
+ ## [0.2.0] — OmniRoute as the pool's primary slot
96
+
97
+ ### Added
98
+
99
+ - OmniRoute, a local OpenAI-compatible gateway, as the pool's primary slot, with
100
+ combo routing, sticky sessions and cooldown on usage limits.
101
+
102
+ ## [0.1.10] — observable self-update outcome
103
+
104
+ ### Changed
105
+
106
+ - The self-update reports its outcome instead of failing silently.
107
+
108
+ ## [0.1.9] — reclaim update locks orphaned by a dead process
109
+
110
+ ### Fixed
111
+
112
+ - An update lock left behind by a dead process is reclaimed rather than blocking
113
+ every later update.
114
+
115
+ ## [0.1.7] — self-update, hermetic tests
116
+
117
+ ### Added
118
+
119
+ - Self-update on restart, and a hermetic test suite that does not read the
120
+ developer's own `~/.config`.
121
+
122
+ ## [0.1.6] — small implement asks, Step 5 Preview pool
123
+
124
+ ### Changed
125
+
126
+ - Implementation asks are split into smaller units.
127
+ - `opencode-go/step-5-preview-free` added to the bundled pool preset.
128
+
129
+ ## [0.1.5] — build/plan may spawn basic and expert
130
+
131
+ ### Changed
132
+
133
+ - `build` and `plan` may spawn `basic` and `expert`, which are not pooled.
134
+
135
+ ## [0.1.4] — flat pool.txt
136
+
137
+ ### Changed
138
+
139
+ - The pool's user config is a flat `~/.config/lacode/pool.txt`, one model per
140
+ line with an optional weight.
141
+
142
+ ## [0.1.3] — ship the updated notification/remote docs
143
+
144
+ ### Changed
145
+
146
+ - Notification and remote-control documentation brought up to date.
147
+
148
+ ## [0.1.1]
149
+
150
+ ### Added
151
+
152
+ - First published release: pooled subagents over a shared set of models.
153
+
154
+ [Unreleased]: https://github.com/lawaty/lacode/compare/v0.4.1...HEAD
155
+ [0.4.1]: https://github.com/lawaty/lacode/compare/v0.4.0...v0.4.1
156
+ [0.4.0]: https://github.com/lawaty/lacode/compare/v0.3.0...v0.4.0
157
+ [0.3.0]: https://github.com/lawaty/lacode/compare/v0.2.2...v0.3.0
158
+ [0.2.2]: https://github.com/lawaty/lacode/compare/v0.2.1...v0.2.2
159
+ [0.2.1]: https://github.com/lawaty/lacode/compare/v0.2.0...v0.2.1
160
+ [0.2.0]: https://github.com/lawaty/lacode/compare/v0.1.10...v0.2.0
161
+ [0.1.10]: https://github.com/lawaty/lacode/compare/v0.1.9...v0.1.10
162
+ [0.1.9]: https://github.com/lawaty/lacode/compare/v0.1.7...v0.1.9
163
+ [0.1.7]: https://github.com/lawaty/lacode/compare/v0.1.6...v0.1.7
164
+ [0.1.6]: https://github.com/lawaty/lacode/compare/v0.1.5...v0.1.6
165
+ [0.1.5]: https://github.com/lawaty/lacode/compare/v0.1.4...v0.1.5
166
+ [0.1.4]: https://github.com/lawaty/lacode/compare/v0.1.3...v0.1.4
167
+ [0.1.3]: https://github.com/lawaty/lacode/compare/v0.1.1...v0.1.3
168
+ [0.1.1]: https://github.com/lawaty/lacode/releases/tag/v0.1.1
package/README.md CHANGED
@@ -13,6 +13,9 @@ desktop.
13
13
  Install it with one config line. It adds agents and commands it finds missing,
14
14
  and it never overwrites one you already defined.
15
15
 
16
+ Upgrading? See [CHANGELOG.md](CHANGELOG.md) — it ships with the package, so it
17
+ is also on disk at `node_modules/@lawaty/lacode/CHANGELOG.md`.
18
+
16
19
  ## Why
17
20
 
18
21
  | Capability | The problem it solves |
@@ -337,6 +340,9 @@ as an opencode provider for you. It is **off by default**: if you do not set
337
340
  disabled for that run, and the integration is per-run.
338
341
  2. Add the plugin block to your opencode config:
339
342
  `["@lawaty/lacode", { "omniroute": { "enabled": true, "apiKey": "<your omniroute key>", "combo": "auto", "models": [] } }]`
343
+ `auto` is the zero-config default, but on a free tier it often resolves to a
344
+ slow chain. [A tested fast combo](#a-tested-fast-combo-free-mix) below is
345
+ ordered for latency and is worth applying on a new client.
340
346
  3. Optionally, to route pooled work through it, make `omniroute/auto 3` the
341
347
  **first** line of `~/.config/lacode/pool.txt`. `pool.txt` is optional user
342
348
  config; the bundled default preset does **not** include OmniRoute.
@@ -426,6 +432,98 @@ the only thing that changes when the gateway is down is that `omniroute/<id>`
426
432
  fails; every other slot keeps working. See
427
433
  [docs/USER-STORIES.md](docs/USER-STORIES.md) US-28.
428
434
 
435
+ ### A tested fast combo: `free-mix`
436
+
437
+ `combo: "auto"` is the zero-configuration default, but `auto` inherits whatever
438
+ the gateway chooses, and on a busy free tier that is frequently the slowest
439
+ chain available. The combo below was **measured** against a live free-tier
440
+ gateway and is ordered for latency rather than for breadth. Apply it during the
441
+ "Setting it up on a client machine" steps above and pooled work is usable
442
+ straight away, instead of starting from a gateway that queues for half a minute
443
+ per call.
444
+
445
+ ```jsonc
446
+ {
447
+ "name": "free-mix",
448
+ // Order decides. Do NOT use "weighted" here — weights let the gateway
449
+ // re-shuffle a model that is returning 429 into first position again.
450
+ "strategy": "priority",
451
+ "models": [
452
+ { "model": "gemini/gemini-3.1-flash-lite", "providerId": "gemini" },
453
+ { "model": "gemini/gemini-3.8-flash", "providerId": "gemini" },
454
+ { "model": "gemini/gemini-3.7-flash", "providerId": "gemini" },
455
+ { "model": "gemini/gemini-3-flash-preview", "providerId": "gemini" },
456
+ { "model": "openrouter/nvidia/nemotron-3-super-120b-a12b:free", "providerId": "openrouter" },
457
+ { "model": "openrouter/nvidia/nemotron-3-ultra-550b-a55b:free", "providerId": "openrouter" },
458
+ { "model": "openrouter/nvidia/nemotron-3.5-lightning:free", "providerId": "openrouter" },
459
+ { "model": "openrouter/cohere/north-mini-code:free", "providerId": "openrouter" },
460
+ { "model": "openrouter/poolside/laguna-s-2.1:free", "providerId": "openrouter" },
461
+ { "model": "openrouter/dots-studio/dots-3-note-preview:free", "providerId": "openrouter" }
462
+ ]
463
+ }
464
+ ```
465
+
466
+ Why this order, from 859 calls over a single day against that gateway:
467
+
468
+ | Model | Observed behaviour | Position |
469
+ | --- | --- | --- |
470
+ | `gemini/gemini-3.1-flash-lite` | **165/172 succeeded (96%)**, ~4.5s median | **first** |
471
+ | `gemini-3.8-flash` / `3.7-flash` / `3-flash-preview` | succeed intermittently, but **burn exactly 30.0s** on a `503` queue-budget timeout when they do not | next |
472
+ | `openrouter/*:free` | **~90% `429`**, but fails *fast* (~1.8s) and occasionally succeeds | last, as a cheap final fallback |
473
+ | `gemini-2.5-flash`, `gemini-2.5-flash-lite` | **`404` on every attempt** — dead ids | **omitted entirely** |
474
+
475
+ The ordering matters because a combo pays for *every* step it walks past. Put the
476
+ 96%-success model first and the common case is a single hop; the 30s models sit
477
+ behind it where they cost nothing, and the 429s sit last where they give up in
478
+ under two seconds.
479
+
480
+ Measured on the same gateway, same day, `max_tokens: 10`:
481
+
482
+ | | Before | After |
483
+ | --- | --- | --- |
484
+ | `free-mix` response | 42.7s / 41.8s / 34.7s | 4.1s / 3.1s / 3.2s / 4.0s / 3.2s |
485
+ | Median | **~41.8s** | **~3.2s** |
486
+ | Provider time burned on calls >25s | **56.9 of 102.4 min (56%)** | — |
487
+
488
+ Before the change, the real call sequence was a `429` after 2.7s, then a `503`
489
+ after 30.0s, then the actual work in 5.7s — roughly **33 seconds of dead time in
490
+ front of every successful request**, and a p90 of 100s when three 30s steps
491
+ chained.
492
+
493
+ These free-tier endpoints change availability constantly, so treat the ordering
494
+ as a **starting point to re-measure, not a permanent truth**. Re-check it with
495
+ OmniRoute's own call logs before assuming it still holds:
496
+
497
+ ```bash
498
+ # one timed request through the gateway
499
+ curl -o /dev/null -w '%{http_code} %{time_starttransfer}s\n' \
500
+ -X POST http://localhost:20128/v1/chat/completions \
501
+ -H "Authorization: Bearer $OMNIROUTE_API_KEY" -H 'Content-Type: application/json' \
502
+ -d '{"model":"free-mix","messages":[{"role":"user","content":"Say OK"}],"max_tokens":10}'
503
+
504
+ # per-model status + duration, straight from OmniRoute's logs
505
+ python3 - <<'PY'
506
+ import json, glob, collections
507
+ d = "/home/<user>/.omniroute/call_logs/<YYYY-MM-DD>"
508
+ g = collections.defaultdict(collections.Counter)
509
+ for f in glob.glob(d + "/*.json"):
510
+ try: s = json.load(open(f))["summary"]
511
+ except Exception: continue
512
+ g[s.get("comboStepId")][s.get("status")] += 1
513
+ for k, v in sorted(g.items(), key=lambda x: str(x[0])):
514
+ print(f"{str(k)[:44]:44} {dict(v)}")
515
+ PY
516
+ ```
517
+
518
+ Apply it with the bundled CLI (`PUT /api/combos/<id>` updates in place, so the
519
+ combo is never briefly missing):
520
+
521
+ ```bash
522
+ omniroute api combos get-api-combos-id- --id <combo-id> --json > free-mix.json
523
+ # edit name/strategy/models in free-mix.json, then:
524
+ omniroute api combos put-api-combos-id- --id <combo-id> --body @free-mix.json
525
+ ```
526
+
429
527
  **A note on mid-session switching.** opencode 1.18.34 exposes no hook to change
430
528
  an agent's main model mid-request, so LaCode cannot swap models inside a running
431
529
  session; use an OmniRoute `combo` (or `auto`) if you want per-request fallback.
@@ -491,8 +589,11 @@ fields are not affected; `permission`, `prompt`, `model`, `description`,
491
589
  ```
492
590
 
493
591
  The hidden pool variants (`explore-1`, `implement-2`, …) inherit
494
- nothing here — add a line per variant you actually spawn, or drop the global
592
+ nothing here — add a line per variant, or drop the global
495
593
  deny and scope it to the agents that should not have browser access instead.
594
+ Note that you cannot *spawn* a variant by name: the pool refuses
595
+ `explore-1` and friends and points you at the bare base, so per-variant
596
+ permissions only ever apply to a spawn the plugin routed itself.
496
597
 
497
598
  Nothing hot-reloads: after editing your config, restart every running opencode
498
599
  process.
@@ -570,6 +671,28 @@ published, because none has been independently measured. The mechanism, the
570
671
  permission rule that makes one writer possible, and what the log lines mean are
571
672
  in [docs/CONTEXT.md](docs/CONTEXT.md).
572
673
 
674
+ ## Managerial directions
675
+
676
+ You can optionally add `manager.md` to `.opencode/context/`. This file is owned
677
+ by you alone and is never written, edited, pruned, or promoted by any agent. It
678
+ outranks the five generated files on focus and settled decisions, but it is
679
+ secondary to the source code: if the code disagrees, the code wins and the
680
+ conflict is reported back to you.
681
+
682
+ The file is per-clone, not committed, and entirely optional: if it is missing,
683
+ agents behave exactly as before. When present, it should contain these three
684
+ sections:
685
+
686
+ - `## Stage`: A snapshot of your current work-in-progress. Rewrite this section
687
+ rather than appending to it to keep it concise. If this file passes roughly 150
688
+ lines, run `/context-review`.
689
+ - `## Confirmed`: Facts and decisions you have personally ratified. Treat these
690
+ as settled, but not as permission to skip reading the code.
691
+ - `## Instructions`: Standing managerial directions for all agents.
692
+
693
+ No agent may write this file at all: the context agent's edit permission denies
694
+ `manager.md` explicitly, so ratification can only ever come from your hand.
695
+
573
696
  ## Cost
574
697
 
575
698
  The mechanism is specific and small: bulk exploration, implementation and
package/dist/agents.js CHANGED
@@ -43,6 +43,7 @@ var DEFAULT_PRESET = join2(PRESETS_DIR, "free-tier.json");
43
43
  var USER_CONFIG_DIR = join2(homedir(), ".config", "lacode");
44
44
  var USER_MODELS_PATH = join2(USER_CONFIG_DIR, "pool.json");
45
45
  var USER_MODELS_TXT = join2(USER_CONFIG_DIR, "pool.txt");
46
+ var CHILD_MEMORY_MS = 30 * 6e4;
46
47
 
47
48
  // src/agents.ts
48
49
  var POOLED_BASES = ["explore", "implement", "context"];
@@ -72,15 +73,9 @@ Every question of the form "what does this file do", "where is X handled", or
72
73
  that proves it. Never one whole refactor handed over in a single piece \u2014
73
74
  see \`# Task size\` below.
74
75
 
75
- The agents you spawn are the pooled \`explore\` / \`implement\` / \`context\`
76
- bases. **Always spawn the BARE base name and let the plugin choose the slot;
77
- never name a \`-N\` variant** (\`explore-1\`..\`explore-4\`, \`implement-1\`..,
78
- \`context-1\`..). The plugin owns which model every pooled spawn runs on: it
79
- picks the next slot from the pool's weights, current load and cooldowns, and
80
- rewrites your spawn to that variant for you. Naming a variant bypasses that
81
- decision, defeats failover, and can pin work to a cooling or unhealthy model.
82
- If you catch yourself choosing a slot number, you are doing the plugin's job \u2014
83
- spawn \`explore\` and let it decide.
76
+ Spawn the BARE pooled base \u2014 \`explore\`, \`implement\` or \`context\`. The pool
77
+ refuses a \`-N\` variant, and a refusal means spawn the bare base, never a
78
+ different number.
84
79
 
85
80
  # When the pool is unavailable
86
81
 
@@ -200,10 +195,8 @@ When you need to understand the codebase:
200
195
  2. Invoke @explore with specific, focused instructions (e.g. "read src/auth.ts", "grep for 'migration' in *.py files", "list the src/ directory")
201
196
  3. @explore will return its findings \u2014 you process them and plan accordingly
202
197
 
203
- Always spawn the BARE pooled base (\`explore\`, \`implement\`, \`context\`) and let
204
- the plugin choose the slot. Never name a \`-N\` variant (\`explore-1\`..\`explore-4\`);
205
- doing so bypasses the pool's weights, load and cooldowns, which is the one thing
206
- the plugin exists to decide.
198
+ Spawn the BARE pooled base (\`explore\`, \`implement\`, \`context\`); the pool
199
+ refuses \`-N\` variants, and a refusal means spawn the bare base, not another number.
207
200
 
208
201
  If the pool cannot serve a spawn \u2014 every slot cooling or exhausted, or repeated
209
202
  failures \u2014 do NOT fall back to reading or planning from your own inspection.
package/dist/index.js CHANGED
@@ -243,6 +243,7 @@ function state(opts = {}) {
243
243
  cooling: /* @__PURE__ */ new Map(),
244
244
  strikes: /* @__PURE__ */ new Map(),
245
245
  activity: /* @__PURE__ */ new Map(),
246
+ children: /* @__PURE__ */ new Map(),
246
247
  reaped: 0,
247
248
  reaping: false,
248
249
  lastSweep: 0,
@@ -284,7 +285,7 @@ function init(s, onWarn) {
284
285
  }
285
286
  function pruneOwn(s) {
286
287
  const now = Date.now();
287
- for (const [key, claim] of s.own) if (now - claim.t > CLAIM_TTL_MS) s.own.delete(key);
288
+ for (const [key, claim] of s.own) if (now - (claim.hb ?? claim.t) > CLAIM_TTL_MS) s.own.delete(key);
288
289
  }
289
290
  function publish(s) {
290
291
  if (!s.dirReady) return;
@@ -349,7 +350,7 @@ function readSiblings(s) {
349
350
  try {
350
351
  const parsed = JSON.parse(readFileSync2(path4, "utf8"));
351
352
  const claims = Object.values(parsed).filter(
352
- (c) => c && typeof c.v === "string" && typeof c.m === "string" && now - c.t <= CLAIM_TTL_MS
353
+ (c) => c && typeof c.v === "string" && typeof c.m === "string" && now - (c.hb ?? c.t) <= CLAIM_TTL_MS
353
354
  );
354
355
  siblings.push({ id: match[1], claims });
355
356
  } catch {
@@ -463,26 +464,77 @@ function snapshot(s) {
463
464
  }
464
465
  var loadOf = (counts, model) => counts.get(model) ?? 0;
465
466
  var underCeiling = (slot, counts) => loadOf(counts, slot.model) < slot.weight;
466
- function pickSlot(s, snap) {
467
+ function roundRobinSequence(slots) {
468
+ const seq = [];
469
+ for (const slot of slots) for (let turn = 0; turn < slot.weight; turn++) seq.push(slot.index);
470
+ return seq;
471
+ }
472
+ var CURSOR_FILE = "cursor.json";
473
+ function readCursor(s) {
474
+ try {
475
+ const parsed = JSON.parse(readFileSync2(join2(s.dir, CURSOR_FILE), "utf8"));
476
+ if (typeof parsed.seq === "number" && Number.isInteger(parsed.seq) && parsed.seq >= 0) return parsed.seq;
477
+ } catch {
478
+ }
479
+ return 0;
480
+ }
481
+ function writeCursor(s, seq) {
482
+ if (!s.dirReady) return;
483
+ const path4 = join2(s.dir, CURSOR_FILE);
484
+ try {
485
+ writeFileSync(`${path4}.tmp`, JSON.stringify({ seq }));
486
+ renameSync(`${path4}.tmp`, path4);
487
+ } catch {
488
+ }
489
+ }
490
+ function pickSlot(s, snap, options) {
491
+ const advance = options?.advance !== false;
467
492
  const slots = s.slots.length > 0 ? s.slots : FALLBACK_SLOTS;
493
+ const seq = roundRobinSequence(slots);
494
+ if (seq.length > 0) {
495
+ const byIndex = new Map(slots.map((slot) => [slot.index, slot]));
496
+ const start = readCursor(s) % seq.length;
497
+ for (let step = 0; step < seq.length; step++) {
498
+ const at = (start + step) % seq.length;
499
+ const slot = byIndex.get(seq[at]);
500
+ if (slot && isAvailable(s, slot.model, snap.cooling) && underCeiling(slot, snap.counts)) {
501
+ if (advance) writeCursor(s, at + 1);
502
+ return slot;
503
+ }
504
+ }
505
+ }
468
506
  const usable = slots.filter((slot) => isAvailable(s, slot.model, snap.cooling));
469
507
  const pool = usable.length > 0 ? usable : slots;
470
- const open = pool.filter((slot) => underCeiling(slot, snap.counts));
471
- if (open.length > 0) return open.reduce((best, slot) => slot.weight > best.weight ? slot : best, open[0]);
472
508
  return pool.reduce((best, slot) => loadOf(snap.counts, slot.model) < loadOf(snap.counts, best.model) ? slot : best, pool[0]);
473
509
  }
474
510
  function agentFor(base, slot) {
475
511
  return `${base}-${slot}`;
476
512
  }
513
+ function manualVariant(name) {
514
+ const direct = /^(.*)-([1-9][0-9]*)$/.exec(name);
515
+ if (!direct) return void 0;
516
+ return BASES.includes(direct[1]) ? direct[1] : void 0;
517
+ }
477
518
  function acquire(s, base, slot, ownerSession, key, target) {
478
- s.own.set(key, { v: base, k: slot.index, m: slot.model, t: Date.now(), s: ownerSession, g: target });
479
- if (target) s.activity.set(target, Date.now());
519
+ const now = Date.now();
520
+ s.own.set(key, { v: base, k: slot.index, m: slot.model, t: now, hb: now, s: ownerSession, g: target });
521
+ if (target) s.activity.set(target, now);
480
522
  publish(s);
481
523
  }
482
524
  function release(s, key) {
483
525
  if (s.own.delete(key)) publish(s);
484
526
  }
527
+ var CHILD_RING = 8;
528
+ var CHILD_MEMORY_MS = 30 * 6e4;
529
+ var CHILD_SLACK_MS = 1e3;
530
+ function noteChild(s, parentID, childID) {
531
+ const ring = s.children.get(parentID) ?? [];
532
+ ring.push({ id: childID, t: Date.now() });
533
+ while (ring.length > CHILD_RING) ring.shift();
534
+ s.children.set(parentID, ring);
535
+ }
485
536
  function bindChild(s, parentID, childID) {
537
+ noteChild(s, parentID, childID);
486
538
  const candidates = [...s.own.entries()].filter(([, c]) => c.s === parentID && !c.g).sort((a, b) => a[1].t - b[1].t);
487
539
  if (candidates.length === 0) return false;
488
540
  candidates[0][1].g = childID;
@@ -490,8 +542,29 @@ function bindChild(s, parentID, childID) {
490
542
  publish(s);
491
543
  return true;
492
544
  }
545
+ function backfillChild(s, claim) {
546
+ if (claim.g || !claim.s) return false;
547
+ const now = Date.now();
548
+ const taken = new Set([...s.own.values()].map((c) => c.g).filter((g) => typeof g === "string"));
549
+ const ring = s.children.get(claim.s) ?? [];
550
+ for (let i = ring.length - 1; i >= 0; i--) {
551
+ const child = ring[i];
552
+ if (now - child.t > CHILD_MEMORY_MS) break;
553
+ if (child.t < claim.t - CHILD_SLACK_MS) continue;
554
+ if (taken.has(child.id)) continue;
555
+ claim.g = child.id;
556
+ s.activity.set(child.id, now);
557
+ publish(s);
558
+ return true;
559
+ }
560
+ return false;
561
+ }
493
562
  function noteActivity(s, sessionID) {
494
- s.activity.set(sessionID, Date.now());
563
+ const now = Date.now();
564
+ s.activity.set(sessionID, now);
565
+ let touched = false;
566
+ for (const claim of s.own.values()) if (claim.g === sessionID) claim.hb = now, touched = true;
567
+ if (touched) publish(s);
495
568
  }
496
569
  function idleFor(s, claim, now) {
497
570
  const target = claim.g;
@@ -558,15 +631,9 @@ Every question of the form "what does this file do", "where is X handled", or
558
631
  that proves it. Never one whole refactor handed over in a single piece \u2014
559
632
  see \`# Task size\` below.
560
633
 
561
- The agents you spawn are the pooled \`explore\` / \`implement\` / \`context\`
562
- bases. **Always spawn the BARE base name and let the plugin choose the slot;
563
- never name a \`-N\` variant** (\`explore-1\`..\`explore-4\`, \`implement-1\`..,
564
- \`context-1\`..). The plugin owns which model every pooled spawn runs on: it
565
- picks the next slot from the pool's weights, current load and cooldowns, and
566
- rewrites your spawn to that variant for you. Naming a variant bypasses that
567
- decision, defeats failover, and can pin work to a cooling or unhealthy model.
568
- If you catch yourself choosing a slot number, you are doing the plugin's job \u2014
569
- spawn \`explore\` and let it decide.
634
+ Spawn the BARE pooled base \u2014 \`explore\`, \`implement\` or \`context\`. The pool
635
+ refuses a \`-N\` variant, and a refusal means spawn the bare base, never a
636
+ different number.
570
637
 
571
638
  # When the pool is unavailable
572
639
 
@@ -686,10 +753,8 @@ When you need to understand the codebase:
686
753
  2. Invoke @explore with specific, focused instructions (e.g. "read src/auth.ts", "grep for 'migration' in *.py files", "list the src/ directory")
687
754
  3. @explore will return its findings \u2014 you process them and plan accordingly
688
755
 
689
- Always spawn the BARE pooled base (\`explore\`, \`implement\`, \`context\`) and let
690
- the plugin choose the slot. Never name a \`-N\` variant (\`explore-1\`..\`explore-4\`);
691
- doing so bypasses the pool's weights, load and cooldowns, which is the one thing
692
- the plugin exists to decide.
756
+ Spawn the BARE pooled base (\`explore\`, \`implement\`, \`context\`); the pool
757
+ refuses \`-N\` variants, and a refusal means spawn the bare base, not another number.
693
758
 
694
759
  If the pool cannot serve a spawn \u2014 every slot cooling or exhausted, or repeated
695
760
  failures \u2014 do NOT fall back to reading or planning from your own inspection.
@@ -1342,6 +1407,7 @@ import { tool } from "@opencode-ai/plugin";
1342
1407
  var SERVICE = "agent-pool";
1343
1408
  var REAP_INTERVAL_MS = 3e4;
1344
1409
  var HANG_FALLBACK_MS = 10 * 60 * 1e3;
1410
+ var ABORT_TIMEOUT_MS = 1e4;
1345
1411
  var agentPoolHooks = (async ({ client, serverUrl }, options) => {
1346
1412
  const opts = options ?? {};
1347
1413
  const s = state(opts);
@@ -1355,14 +1421,28 @@ var agentPoolHooks = (async ({ client, serverUrl }, options) => {
1355
1421
  };
1356
1422
  init(s, (m) => void warnOnce(m));
1357
1423
  const abortSession = async (sessionID) => {
1358
- if (!serverUrl) return false;
1424
+ if (!serverUrl) return { ok: false, why: "no serverUrl: this process has no HTTP server to abort through" };
1359
1425
  try {
1360
- await fetch(new URL(`/session/${sessionID}/abort`, serverUrl).toString(), { method: "POST" });
1361
- return true;
1362
- } catch {
1363
- return false;
1426
+ const res = await fetch(new URL(`/session/${sessionID}/abort`, serverUrl).toString(), {
1427
+ method: "POST",
1428
+ signal: AbortSignal.timeout(ABORT_TIMEOUT_MS)
1429
+ });
1430
+ if (!res.ok) return { ok: false, why: `gateway answered HTTP ${res.status}` };
1431
+ const body = (await res.text()).trim();
1432
+ if (body === "false") return { ok: false, why: "server reported the session was not aborted" };
1433
+ return { ok: true, why: "" };
1434
+ } catch (error) {
1435
+ return { ok: false, why: String(error) };
1364
1436
  }
1365
1437
  };
1438
+ const MAX_ABORT_ATTEMPTS = 3;
1439
+ const coolForHang = async (claim, log2) => {
1440
+ const now = Date.now();
1441
+ const prior = s.cooling.get(claim.m);
1442
+ if (prior && prior.until > now) return;
1443
+ const strike = recordStrike(s, claim.m, "hang", HANG_FALLBACK_MS);
1444
+ if (strike) await log2("warn", `cooling ${claim.m} for ${Math.round((strike.until - now) / 1e3)}s (hang)`);
1445
+ };
1366
1446
  const reapStuck = async () => {
1367
1447
  if (s.reaping) return;
1368
1448
  s.reaping = true;
@@ -1370,21 +1450,53 @@ var agentPoolHooks = (async ({ client, serverUrl }, options) => {
1370
1450
  try {
1371
1451
  const stuck = findStuck(s);
1372
1452
  if (stuck.length === 0) return;
1373
- releaseStuck(s, stuck.map((x) => x.key));
1374
- for (const { claim } of stuck) {
1375
- const killed = claim.g ? await abortSession(claim.g) : false;
1453
+ const released = [];
1454
+ for (const { key, claim } of stuck) {
1455
+ if (!claim.g) backfillChild(s, claim);
1456
+ const idle = idleFor(s, claim, Date.now());
1457
+ const since = Number.isFinite(idle) ? `${Math.round(idle / 1e3)}s of silence` : "no child session ever bound";
1458
+ const age = Math.round((Date.now() - claim.t) / 1e3);
1459
+ if (claim.g) {
1460
+ const kill = await abortSession(claim.g);
1461
+ if (kill.ok) {
1462
+ released.push(key);
1463
+ s.reaped++;
1464
+ await log(
1465
+ "warn",
1466
+ `hung ${claim.v} on ${claim.m} after ${age}s with ${since}; aborted session ${claim.g}`,
1467
+ { base: claim.v, model: claim.m, slot: claim.k, session: claim.g, aborted: true }
1468
+ );
1469
+ await coolForHang(claim, log);
1470
+ continue;
1471
+ }
1472
+ claim.n = (claim.n ?? 0) + 1;
1473
+ if (claim.n < MAX_ABORT_ATTEMPTS) {
1474
+ await log(
1475
+ "warn",
1476
+ `could not abort hung ${claim.v} session ${claim.g} on ${claim.m} (${kill.why}); claim held, retry ${claim.n}/${MAX_ABORT_ATTEMPTS}`,
1477
+ { base: claim.v, model: claim.m, slot: claim.k, session: claim.g, aborted: false }
1478
+ );
1479
+ continue;
1480
+ }
1481
+ released.push(key);
1482
+ s.reaped++;
1483
+ await log(
1484
+ "error",
1485
+ `GIVING UP on hung ${claim.v} session ${claim.g} on ${claim.m}: ${kill.why} after ${claim.n} attempts. The claim is released but the session is still running and must be killed by hand.`,
1486
+ { base: claim.v, model: claim.m, slot: claim.k, session: claim.g, aborted: false }
1487
+ );
1488
+ continue;
1489
+ }
1490
+ released.push(key);
1376
1491
  s.reaped++;
1377
- await log(
1378
- "warn",
1379
- `hung ${claim.v} on ${claim.m} after ${Math.round((Date.now() - claim.t) / 1e3)}s with ${Math.round(idleFor(s, claim, Date.now()) / 1e3)}s of silence; ${killed ? `aborted session ${claim.g}` : "no session to abort"}`,
1380
- { base: claim.v, model: claim.m, slot: claim.k, session: claim.g, aborted: killed }
1381
- );
1382
- const now = Date.now();
1383
- const prior = s.cooling.get(claim.m);
1384
- if (prior && prior.until > now) continue;
1385
- const strike = recordStrike(s, claim.m, "hang", HANG_FALLBACK_MS);
1386
- if (strike) await log("warn", `cooling ${claim.m} for ${Math.round((strike.until - now) / 1e3)}s (hang)`);
1492
+ await log("warn", `released unbound ${claim.v} claim on ${claim.m} after ${age}s (${since}); model not cooled`, {
1493
+ base: claim.v,
1494
+ model: claim.m,
1495
+ slot: claim.k,
1496
+ aborted: false
1497
+ });
1387
1498
  }
1499
+ if (released.length > 0) releaseStuck(s, released);
1388
1500
  } catch (error) {
1389
1501
  await log("error", `reaper failed: ${String(error)}`);
1390
1502
  } finally {
@@ -1494,9 +1606,19 @@ var agentPoolHooks = (async ({ client, serverUrl }, options) => {
1494
1606
  },
1495
1607
  "tool.execute.before": async (input, output) => {
1496
1608
  if (input.tool !== "task") return;
1609
+ const requested = output.args?.subagent_type;
1610
+ if (typeof requested !== "string") return;
1611
+ const manual = manualVariant(requested);
1612
+ if (manual) {
1613
+ await log("warn", `refused manual spawn of slot variant ${requested}; spawn "${manual}" instead`, {
1614
+ variant: requested,
1615
+ base: manual
1616
+ });
1617
+ throw new Error(
1618
+ `${requested} is a pool slot variant and cannot be spawned by name. Naming it lets the caller pick the provider: it skips the round-robin rotation and the slot's ceiling, so one provider takes every concurrent claim. Spawn "${manual}" and let the pool choose the slot.`
1619
+ );
1620
+ }
1497
1621
  try {
1498
- const requested = output.args?.subagent_type;
1499
- if (typeof requested !== "string") return;
1500
1622
  await reapStuck();
1501
1623
  refreshCatalog(s, serverUrl);
1502
1624
  const snap = snapshot(s);
@@ -1518,11 +1640,6 @@ var agentPoolHooks = (async ({ client, serverUrl }, options) => {
1518
1640
  });
1519
1641
  return;
1520
1642
  }
1521
- const direct = /^(.*)-([1-9][0-9]*)$/.exec(requested);
1522
- if (direct && BASES.includes(direct[1])) {
1523
- const slot = s.slots.find((sl) => sl.index === Number(direct[2]));
1524
- if (slot) acquire(s, direct[1], slot, input.sessionID, claimKey(input.sessionID, input.callID));
1525
- }
1526
1643
  } catch (error) {
1527
1644
  await log("error", `routing failed, spawn unchanged: ${String(error)}`);
1528
1645
  }
@@ -1549,7 +1666,7 @@ var agentPoolHooks = (async ({ client, serverUrl }, options) => {
1549
1666
  };
1550
1667
  for (const claim of snap.own.values()) add(claim.m, claim.v);
1551
1668
  for (const sibling of snap.siblings) for (const claim of sibling.claims) add(claim.m, claim.v);
1552
- const next = pickSlot(s, snap);
1669
+ const next = pickSlot(s, snap, { advance: false });
1553
1670
  const line = (slot) => {
1554
1671
  const entry = byModel.get(slot.model);
1555
1672
  const users = entry && entry.bases.size > 0 ? [...entry.bases].map(([b, n]) => `${b}x${n}`).join(" ") : "idle";
@@ -1571,7 +1688,7 @@ var agentPoolHooks = (async ({ client, serverUrl }, options) => {
1571
1688
  title: `agent-pool: ${total} task(s) in flight, ${coolingNow.length} model(s) cooling`,
1572
1689
  output: [
1573
1690
  `shared pool over ${slots.length} model(s) from ${s.origin}; bases: ${BASES.join(", ")}
1574
- priority is weight: the heaviest slot under its ceiling takes the next spawn (FULL = at ceiling, used only when every slot is)`,
1691
+ round robin in slot order, each slot taking as many turns as its weight says, so no slot is starved while another drains (FULL = at ceiling, used only when every slot is)`,
1575
1692
  ...slots.map(line),
1576
1693
  "",
1577
1694
  "by process:",
package/dist/pool.js CHANGED
@@ -243,6 +243,7 @@ function state(opts = {}) {
243
243
  cooling: /* @__PURE__ */ new Map(),
244
244
  strikes: /* @__PURE__ */ new Map(),
245
245
  activity: /* @__PURE__ */ new Map(),
246
+ children: /* @__PURE__ */ new Map(),
246
247
  reaped: 0,
247
248
  reaping: false,
248
249
  lastSweep: 0,
@@ -284,7 +285,7 @@ function init(s, onWarn) {
284
285
  }
285
286
  function pruneOwn(s) {
286
287
  const now = Date.now();
287
- for (const [key, claim] of s.own) if (now - claim.t > CLAIM_TTL_MS) s.own.delete(key);
288
+ for (const [key, claim] of s.own) if (now - (claim.hb ?? claim.t) > CLAIM_TTL_MS) s.own.delete(key);
288
289
  }
289
290
  function publish(s) {
290
291
  if (!s.dirReady) return;
@@ -349,7 +350,7 @@ function readSiblings(s) {
349
350
  try {
350
351
  const parsed = JSON.parse(readFileSync2(path, "utf8"));
351
352
  const claims = Object.values(parsed).filter(
352
- (c) => c && typeof c.v === "string" && typeof c.m === "string" && now - c.t <= CLAIM_TTL_MS
353
+ (c) => c && typeof c.v === "string" && typeof c.m === "string" && now - (c.hb ?? c.t) <= CLAIM_TTL_MS
353
354
  );
354
355
  siblings.push({ id: match[1], claims });
355
356
  } catch {
@@ -463,26 +464,77 @@ function snapshot(s) {
463
464
  }
464
465
  var loadOf = (counts, model) => counts.get(model) ?? 0;
465
466
  var underCeiling = (slot, counts) => loadOf(counts, slot.model) < slot.weight;
466
- function pickSlot(s, snap) {
467
+ function roundRobinSequence(slots) {
468
+ const seq = [];
469
+ for (const slot of slots) for (let turn = 0; turn < slot.weight; turn++) seq.push(slot.index);
470
+ return seq;
471
+ }
472
+ var CURSOR_FILE = "cursor.json";
473
+ function readCursor(s) {
474
+ try {
475
+ const parsed = JSON.parse(readFileSync2(join2(s.dir, CURSOR_FILE), "utf8"));
476
+ if (typeof parsed.seq === "number" && Number.isInteger(parsed.seq) && parsed.seq >= 0) return parsed.seq;
477
+ } catch {
478
+ }
479
+ return 0;
480
+ }
481
+ function writeCursor(s, seq) {
482
+ if (!s.dirReady) return;
483
+ const path = join2(s.dir, CURSOR_FILE);
484
+ try {
485
+ writeFileSync(`${path}.tmp`, JSON.stringify({ seq }));
486
+ renameSync(`${path}.tmp`, path);
487
+ } catch {
488
+ }
489
+ }
490
+ function pickSlot(s, snap, options) {
491
+ const advance = options?.advance !== false;
467
492
  const slots = s.slots.length > 0 ? s.slots : FALLBACK_SLOTS;
493
+ const seq = roundRobinSequence(slots);
494
+ if (seq.length > 0) {
495
+ const byIndex = new Map(slots.map((slot) => [slot.index, slot]));
496
+ const start = readCursor(s) % seq.length;
497
+ for (let step = 0; step < seq.length; step++) {
498
+ const at = (start + step) % seq.length;
499
+ const slot = byIndex.get(seq[at]);
500
+ if (slot && isAvailable(s, slot.model, snap.cooling) && underCeiling(slot, snap.counts)) {
501
+ if (advance) writeCursor(s, at + 1);
502
+ return slot;
503
+ }
504
+ }
505
+ }
468
506
  const usable = slots.filter((slot) => isAvailable(s, slot.model, snap.cooling));
469
507
  const pool = usable.length > 0 ? usable : slots;
470
- const open = pool.filter((slot) => underCeiling(slot, snap.counts));
471
- if (open.length > 0) return open.reduce((best, slot) => slot.weight > best.weight ? slot : best, open[0]);
472
508
  return pool.reduce((best, slot) => loadOf(snap.counts, slot.model) < loadOf(snap.counts, best.model) ? slot : best, pool[0]);
473
509
  }
474
510
  function agentFor(base, slot) {
475
511
  return `${base}-${slot}`;
476
512
  }
513
+ function manualVariant(name) {
514
+ const direct = /^(.*)-([1-9][0-9]*)$/.exec(name);
515
+ if (!direct) return void 0;
516
+ return BASES.includes(direct[1]) ? direct[1] : void 0;
517
+ }
477
518
  function acquire(s, base, slot, ownerSession, key, target) {
478
- s.own.set(key, { v: base, k: slot.index, m: slot.model, t: Date.now(), s: ownerSession, g: target });
479
- if (target) s.activity.set(target, Date.now());
519
+ const now = Date.now();
520
+ s.own.set(key, { v: base, k: slot.index, m: slot.model, t: now, hb: now, s: ownerSession, g: target });
521
+ if (target) s.activity.set(target, now);
480
522
  publish(s);
481
523
  }
482
524
  function release(s, key) {
483
525
  if (s.own.delete(key)) publish(s);
484
526
  }
527
+ var CHILD_RING = 8;
528
+ var CHILD_MEMORY_MS = 30 * 6e4;
529
+ var CHILD_SLACK_MS = 1e3;
530
+ function noteChild(s, parentID, childID) {
531
+ const ring = s.children.get(parentID) ?? [];
532
+ ring.push({ id: childID, t: Date.now() });
533
+ while (ring.length > CHILD_RING) ring.shift();
534
+ s.children.set(parentID, ring);
535
+ }
485
536
  function bindChild(s, parentID, childID) {
537
+ noteChild(s, parentID, childID);
486
538
  const candidates = [...s.own.entries()].filter(([, c]) => c.s === parentID && !c.g).sort((a, b) => a[1].t - b[1].t);
487
539
  if (candidates.length === 0) return false;
488
540
  candidates[0][1].g = childID;
@@ -490,8 +542,29 @@ function bindChild(s, parentID, childID) {
490
542
  publish(s);
491
543
  return true;
492
544
  }
545
+ function backfillChild(s, claim) {
546
+ if (claim.g || !claim.s) return false;
547
+ const now = Date.now();
548
+ const taken = new Set([...s.own.values()].map((c) => c.g).filter((g) => typeof g === "string"));
549
+ const ring = s.children.get(claim.s) ?? [];
550
+ for (let i = ring.length - 1; i >= 0; i--) {
551
+ const child = ring[i];
552
+ if (now - child.t > CHILD_MEMORY_MS) break;
553
+ if (child.t < claim.t - CHILD_SLACK_MS) continue;
554
+ if (taken.has(child.id)) continue;
555
+ claim.g = child.id;
556
+ s.activity.set(child.id, now);
557
+ publish(s);
558
+ return true;
559
+ }
560
+ return false;
561
+ }
493
562
  function noteActivity(s, sessionID) {
494
- s.activity.set(sessionID, Date.now());
563
+ const now = Date.now();
564
+ s.activity.set(sessionID, now);
565
+ let touched = false;
566
+ for (const claim of s.own.values()) if (claim.g === sessionID) claim.hb = now, touched = true;
567
+ if (touched) publish(s);
495
568
  }
496
569
  function idleFor(s, claim, now) {
497
570
  const target = claim.g;
@@ -555,6 +628,7 @@ export {
555
628
  USER_MODELS_TXT,
556
629
  acquire,
557
630
  agentFor,
631
+ backfillChild,
558
632
  bindChild,
559
633
  claimKey,
560
634
  coolProvider,
@@ -568,6 +642,7 @@ export {
568
642
  isLimitText,
569
643
  isQuotaReason,
570
644
  isUnavailableText,
645
+ manualVariant,
571
646
  noteActivity,
572
647
  parseSlotText,
573
648
  parseSlots,
@@ -585,6 +660,7 @@ export {
585
660
  releaseStuck,
586
661
  resolveModels,
587
662
  retryAfterMs,
663
+ roundRobinSequence,
588
664
  selfFile,
589
665
  slotFor,
590
666
  snapshot,
@@ -96,13 +96,13 @@ The reconciliation with US-7 is that **acceptance and endorsement are different
96
96
  - [ ] Given every model is cooling, When a spawn is routed, Then the least-loaded slot is chosen anyway (degrade, never block).
97
97
  - [ ] Given a limit error names a reset time, When the cooldown is recorded, Then the stated reset is used instead of a guessed backoff.
98
98
 
99
- ### US-5: Weighted preference drains preferred providers first
100
- **As a** user, **I want** to express preference between models via weights, **so that** the pool prefers the cheapest/freest provider up to its ceiling.
99
+ ### US-5: Weighted round-robin gives every slot a turn per cycle
100
+ **As a** user, **I want** weights to say how many turns each slot takes per rotation cycle, **so that** no provider is drained to its limit while the others sit idle.
101
101
 
102
102
  **Story ID(s) for tests:** US-5
103
103
  **Acceptance criteria:**
104
- - [ ] Given a heavy slot and a light slot, When spawns are routed below both ceilings, Then the heavier slot is filled before the lighter one.
105
- - [ ] Given a slot is at its weight ceiling, When another spawn is routed, Then the pick moves to the next slot under its ceiling.
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.
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
108
  - [ ] Given per-slot load is read, When it is asserted, Then a slot's load never exceeds its weight ceiling except by overflow.
@@ -115,7 +115,7 @@ The reconciliation with US-7 is that **acceptance and endorsement are different
115
115
  - [ ] Given claims are held by several processes, When the status view is requested, Then it shows every process's claims and the aggregate load per slot.
116
116
  - [ ] Given a model is cooling with a stated reason, When the status view is requested, Then the model is listed as cooling together with that reason.
117
117
  - [ ] Given a claim file is older than the dead-file threshold, When the status view is computed, Then the stale claim is pruned rather than counted.
118
- - [ ] Given a slot is at its ceiling, When the status view is requested, Then it is marked as the slot the next spawn would take.
118
+ - [ ] Given a slot is at its ceiling, When the status view is requested, Then it is not marked as the slot the next spawn would take, because the rotation's turns are skipped for a full slot until it frees a claim.
119
119
 
120
120
  ### US-7: Non-free models still work
121
121
  **As a** user who wants a paid model in the pool, **I want** to supply any model list, **so that** the pool is not restricted to free tiers.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lawaty/lacode",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
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",
@@ -45,6 +45,7 @@
45
45
  "presets",
46
46
  "sounds",
47
47
  "LICENSE",
48
+ "CHANGELOG.md",
48
49
  "README.md",
49
50
  "docs"
50
51
  ],
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 priority. Absent = 1 |
65
+ | `weight` | no | soft ceiling on concurrent claims, and turns taken per rotation. 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,17 +91,28 @@ because the pool requires it.
91
91
 
92
92
  ## Weight, precisely
93
93
 
94
- A weight is a count of concurrent sessions, used as both a ceiling and a
95
- priority:
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**:
96
97
 
97
- 1. the **highest-weight** slot still under its own ceiling takes the next spawn
98
- 2. so slot 1 fills to its number before slot 2 sees any work at all
98
+ 1. the slot list is expanded into a run in which each slot appears once per
99
+ unit of its weight — `[3,2,1,1]` becomes `1,1,1,2,2,3,4` — and the next
100
+ spawn takes the turn a persistent cursor (a file in the pool directory,
101
+ shared by every opencode process) points at
102
+ 2. a turn whose slot is cooling or already at its ceiling is **skipped**, and
103
+ the rotation resumes at the following turn
99
104
  3. once **every** slot is at its ceiling, overflow goes to the least loaded slot —
100
105
  a burst past the summed ceilings degrades to even spreading and never blocks
101
106
 
107
+ Weight is no longer a priority: a heavy slot is not drained to its ceiling
108
+ before the others see any work. Every slot gets a turn each cycle, so a fast
109
+ provider with a large usage window is used early in the cycle while it is
110
+ still fast — instead of one provider being refilled until it hits its limit
111
+ and its 429s displace every agent type at once.
112
+
102
113
  Equal weights give a plain round robin in list order. List order is the
103
- tie-break, so put the models you want busiest first and treat the bottom of the
104
- list as reserve capacity.
114
+ rotation itself, so put the models you want taking the earlier turns of a
115
+ cycle at the top and the ones you want taking the later turns at the bottom.
105
116
 
106
117
  Spreading at least two providers across the slots is worth doing deliberately: a
107
118
  provider-wide rate limit takes out every slot on that provider at once.
@@ -14,20 +14,34 @@
14
14
  "route at an agent that does not exist. Extra entries are dropped with a warning.",
15
15
  "",
16
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. Priority is that same number, so the rule is:",
18
- " 1. the HIGHEST-weight slot that is still under its ceiling takes the spawn",
19
- " 2. slot 1 therefore fills to its number before slot 2 sees any work at all",
17
+ "slot holds -- not a share of the work and not a priority. Routing is ORDER-FIRST",
18
+ "WEIGHTED ROUND-ROBIN: the slot list is walked IN ORDER and each slot takes as many",
19
+ "consecutive turns as its weight says, so weights 3,2,1,1 produce the run 1,1,1,2,2,3,4,",
20
+ "which then repeats. The rule is:",
21
+ " 1. a persistent cursor in the pool directory marks where in that run the next spawn",
22
+ " lands, so every opencode process on the machine shares one rotation",
23
+ " 2. a turn is SKIPPED only when its slot is cooling or already at its ceiling, and the",
24
+ " rotation resumes at the following turn",
20
25
  " 3. once EVERY slot is at its ceiling, the overflow goes to the least loaded slot,",
21
26
  " so a burst past the summed ceilings degrades to even spreading and never blocks",
22
27
  "",
23
- "With this file: space-bunny takes 3 sessions to itself, then step-5-preview",
28
+ "With this file: space-bunny takes 3 consecutive turns, then step-5-preview",
24
29
  "(opencode-go) takes 2, then big-pickle and step-5-preview (Zen) take 1 each -- sequence 1,1,1,2,2,3,4,...",
25
30
  "Equal weights give a plain round robin in list order. 2,2,1,1 reproduces the old",
26
31
  "primary/overflow cycle exactly, which is why the old tier values still work.",
27
32
  "",
33
+ "Round-robin rather than heaviest-first because re-prioritising the heaviest slot on",
34
+ "EVERY spawn drained the fastest provider's free-tier window early: one provider",
35
+ "absorbed all work up to its ceiling and kept being refilled the moment a claim",
36
+ "completed, while the other slots sat idle -- until the fast provider hit its limit and",
37
+ "its 429s displaced every agent type at once. Every slot a turn per cycle puts a fast",
38
+ "slot with a large usage window to work early in the cycle while it is still fast, and",
39
+ "no provider's quota sits unused while another's is drained.",
40
+ "",
28
41
  "Absent means weight 1. Must be a number in (0, 100]; anything else warns and uses 1.",
29
- "List order is the tie-break between slots of equal weight, so put the ones you want",
30
- "first and treat the bottom of the list as the reserve.",
42
+ "List order sets the rotation for every slot, not just a tie-break: earlier positions",
43
+ "take their turns earlier in each cycle, so put the slots you want worked first at the",
44
+ "top of the list.",
31
45
  "",
32
46
  "COST IS THE DEFAULT, NOT A CONSTRAINT. These four slots are free because that is what",
33
47
  "the pool is FOR: mechanical work -- reading files, grepping, mechanical edits, cartography.",