@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 +168 -0
- package/README.md +124 -1
- package/dist/agents.js +6 -13
- package/dist/index.js +165 -48
- package/dist/pool.js +84 -8
- package/docs/USER-STORIES.md +5 -5
- package/package.json +2 -1
- package/presets/README.md +18 -7
- package/presets/free-tier.json +20 -6
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
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
204
|
-
|
|
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
|
|
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
|
-
|
|
479
|
-
|
|
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
|
-
|
|
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
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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
|
-
|
|
690
|
-
|
|
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(), {
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
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
|
-
|
|
1374
|
-
for (const { claim } of stuck) {
|
|
1375
|
-
|
|
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
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
479
|
-
|
|
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
|
-
|
|
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,
|
package/docs/USER-STORIES.md
CHANGED
|
@@ -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
|
|
100
|
-
**As a** user, **I want** to
|
|
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
|
|
105
|
-
- [ ] Given a slot is at its weight ceiling, When
|
|
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
|
+
"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
|
|
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
|
|
95
|
-
|
|
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
|
|
98
|
-
|
|
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
|
-
|
|
104
|
-
|
|
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.
|
package/presets/free-tier.json
CHANGED
|
@@ -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
|
|
18
|
-
"
|
|
19
|
-
"
|
|
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
|
|
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
|
|
30
|
-
"
|
|
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.",
|