bullswarm 0.32.0 → 0.32.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 +80 -0
- package/GOAL.md +1 -1
- package/docs/guide/routing.md +81 -10
- package/docs/reference/cli.md +5 -2
- package/docs/reference/configuration.md +1 -1
- package/docs/reference/providers.md +5 -5
- package/docs/workflow-design.md +1 -1
- package/mods/bullswarm/hooks/pane.tsx +20 -2
- package/mods/bullswarm/hooks/pools.ts +2 -0
- package/mods/bullswarm/types/index.d.ts +4 -0
- package/package.json +1 -1
- package/providers/contrib/README.md +1 -1
- package/providers/contrib/command-code/provider.mjs +13 -5
- package/providers/contrib/{opencode2 → opencode}/connector.json +2 -1
- package/providers/contrib/{opencode2 → opencode}/provider.mjs +3 -3
- package/skill/references/providers.md +1 -1
- package/src/cli.js +56 -8
- package/src/help.js +7 -4
- package/src/lib/cli-flags.js +1 -1
- package/src/lib/config.js +73 -2
- package/src/lib/forecast.js +4 -2
- package/src/lib/route.js +173 -46
- package/src/lib/state.js +395 -3
- package/src/lib/strategy.js +2 -2
- package/src/lib/usage.js +14 -0
- package/src/meters/framework.js +55 -1
- package/src/meters/registry.js +112 -13
- package/src/provider-kit.js +9 -3
- package/src/providers/claude-code/provider.mjs +9 -3
- package/src/providers/codex/provider.mjs +9 -4
- package/src/providers/grok/provider.mjs +9 -4
- package/src/workflow/cli.js +51 -1
- package/src/workflow/v2-dispatch.js +183 -26
- package/src/workflow/v2-outcome.js +4 -1
- package/src/workflow/v2-runtime.js +43 -10
- package/src/workflow/v2-state.js +7 -1
- package/src/workflow/watch-cli.js +43 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,85 @@
|
|
|
1
1
|
# bullswarm changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## 0.32.1 — free models first, with graceful failover
|
|
6
|
+
|
|
7
|
+
- meters: rate-limited or failed live reads now persist a per-pool negative-cache hold (honouring `Retry-After` or the five-minute freshness window), and `pools`/the Claude Mod identify the stale snapshot's error and retry time.
|
|
8
|
+
- rename: the OpenCode pool is now `opencode`; the first run migrates saved
|
|
9
|
+
state, routing, provider, and meter names automatically, while historical
|
|
10
|
+
workflow run records keep their recorded `opencode2` name.
|
|
11
|
+
- routing: a pool whose model for the effort tier costs nothing is ranked ahead
|
|
12
|
+
of every metered pool while it is healthy. Free-ness is per (pool, effort
|
|
13
|
+
tier) and comes from the connector — `modelProfiles[].free`, or a model name
|
|
14
|
+
carrying a standalone `free` segment. The free tier sits below the forecast
|
|
15
|
+
gate and the 5-hour headroom tier and above expiring-soon urgency, so an
|
|
16
|
+
expiring metered window can now go unspent while free work runs; the ranking
|
|
17
|
+
among metered pools is unchanged. The reason reads `free pool first: <pool>
|
|
18
|
+
(free model <model>, …) · metered pools ranked below free: <pool> <surplus>`.
|
|
19
|
+
- routing: a pool that stalls, returns a server error, or (on a free pool) hands
|
|
20
|
+
back literally empty output takes a strike. The first is recorded; the second
|
|
21
|
+
consecutive one soft-benches the pool for a 10-minute cooldown, after which it
|
|
22
|
+
returns automatically. The strike count survives the cooldown and is cleared
|
|
23
|
+
only by a success. Auth is untouched: an upstream auth failure still
|
|
24
|
+
quarantines and still spreads across a credential group. An answer the
|
|
25
|
+
verifier judged thin but not empty stays semantic and is not retried
|
|
26
|
+
elsewhere.
|
|
27
|
+
- workflow: a free pool's silence clock is the median wall time of its own
|
|
28
|
+
recorded runs at that (pool, effort) rung — the `p50`, once 3 runs exist there
|
|
29
|
+
— with a 5-minute floor, instead of the one-hour default a metered pool keeps.
|
|
30
|
+
The multiplier on that median is 1 (`FREE_STALL_P50_FACTOR`): a worker silent
|
|
31
|
+
for as long as the whole rung usually takes has stopped working. Stalled
|
|
32
|
+
attempts are excluded from the median so a pool cannot tighten its own
|
|
33
|
+
threshold by stalling.
|
|
34
|
+
- workflow: a stalled attempt ends, keeps its partial output on disk (the retry
|
|
35
|
+
writes a new `-attempt-N` file beside it, never over it), releases its
|
|
36
|
+
in-flight ledger entry, and re-dispatches the same action on the next eligible
|
|
37
|
+
pool in the same run. The attempt record and `attempt.finished` carry
|
|
38
|
+
`stalled`, `partialOutput`, `silentSec` and `willRetry`; a new `pool.benched` event names
|
|
39
|
+
the pool, reason, strike count and deadline; the retry's reason is prefixed
|
|
40
|
+
`fallback from <pool> after stall <n>s`. A free-pool stall (and the
|
|
41
|
+
free-only literally-empty-output provider reclassification) does not spend
|
|
42
|
+
`maxMechanicalRetries`; the tried-set is the bound and each pool is tried at
|
|
43
|
+
most once for the action. A metered-pool stall keeps the mechanical retry
|
|
44
|
+
accounting.
|
|
45
|
+
- workflow: evidence and acceptance steps are exempt from the free tier and
|
|
46
|
+
route on pace as before, with one preference — a pool that wrote the work
|
|
47
|
+
being judged is chosen last, and only when no other pool is eligible, which
|
|
48
|
+
the reason then says (`evidence step: only the writer pool <pool> is
|
|
49
|
+
eligible`). This restores, in prefer-not rather than forbid form, the steering
|
|
50
|
+
removed in `eb83b79`; the reason line now names the exception, which its
|
|
51
|
+
absence was half the reason for that removal.
|
|
52
|
+
- pools: `bullswarm pools` prints `free=<model>` for a pool whose model costs
|
|
53
|
+
nothing (`free=<tier>:<model>` when it differs per effort tier, since `pools`
|
|
54
|
+
names no lane), `BENCHED until <time> (<reason>, <n> strikes)` for a benched
|
|
55
|
+
pool, and `strikes=<n>(<reason>)` for one carrying an uncounted-out strike. It
|
|
56
|
+
sweeps expired benches the same way it sweeps expired quarantines.
|
|
57
|
+
- routing: measured pacing rates now charge timed in-flight work at the actual
|
|
58
|
+
`rate × remaining minutes` with no 3-point floor, so an expiring-soon pool
|
|
59
|
+
with a known burn rate is not demoted by a tie-breaker meant for unmeasured
|
|
60
|
+
pools. `config.inflightPenaltyPct` remains the per-agent fallback for pools
|
|
61
|
+
without a measured rate (and for in-flight records with unknown duration).
|
|
62
|
+
- workflow: every attempt record now carries `routeWhy` (the router's reason)
|
|
63
|
+
and `routeCandidates` (each pool's effective surplus, urgency state and
|
|
64
|
+
pacing forecast at pick time); `workflow action show --json` and
|
|
65
|
+
`runs result --json` print them, and older state files still load.
|
|
66
|
+
|
|
67
|
+
- routing: a pool whose pacing window is about to reset is `draining` only
|
|
68
|
+
when its forecast is at or above 95% *and* ahead of the window's own clock,
|
|
69
|
+
the same clock-relative shape the 5h near-limit line already has. A pool on
|
|
70
|
+
or behind pace keeps spending right up to its reset instead of being ranked
|
|
71
|
+
last by a fixed line (observed 2026-09-16: command-code at 94.9% used with
|
|
72
|
+
98% of the month gone was passed over while +3 points of quota expired).
|
|
73
|
+
The skip reason now reads `expiring but draining (forecast >= 95% and past
|
|
74
|
+
its clock): <pool> <forecast>% (<elapsed>% elapsed)`.
|
|
75
|
+
- workflow goal: refuses to launch a duplicate of a run that is already going.
|
|
76
|
+
Before anything is validated or launched, an ongoing run with the same goal
|
|
77
|
+
text and cwd exits 2, naming that run's shortId, age, and watch command
|
|
78
|
+
(JSON: `{"error":"duplicate-goal",...}`); `--again` starts the copy anyway
|
|
79
|
+
(observed 2026-09-16: a caller whose JSON parser failed on the first
|
|
80
|
+
launch's output retried five seconds later and two identical workflows ran
|
|
81
|
+
side by side in the same directory).
|
|
82
|
+
|
|
3
83
|
## 0.32.0 — the dashboard is the main screen
|
|
4
84
|
|
|
5
85
|
- wording: a program run's dependency levels are called phases everywhere the
|
package/GOAL.md
CHANGED
|
@@ -35,7 +35,7 @@ A working prototype installed at `~/.bullswarm/` with:
|
|
|
35
35
|
2. **Connector registry** (`~/.bullswarm/connectors/*.json`): declarative per-CLI
|
|
36
36
|
spawn command, auth-failure signatures, output extraction, verify contract,
|
|
37
37
|
quirk fields (e.g. PWD resolution). Seeded with codex, grok,
|
|
38
|
-
command-code,
|
|
38
|
+
command-code, opencode — extracted from the proven `/offload` skill.
|
|
39
39
|
3. **Meter layer**: per-pool window definition (5h / weekly / none) with
|
|
40
40
|
programmatic readers where providers expose usage, manual declaration
|
|
41
41
|
(`--meter pool=window,pct`) where they don't. Unmetered pools pace as
|
package/docs/guide/routing.md
CHANGED
|
@@ -9,19 +9,20 @@ After this page you can predict which pool a task will go to, read the reason li
|
|
|
9
9
|
|
|
10
10
|
## The order decisions are made
|
|
11
11
|
|
|
12
|
-
Routing runs the same
|
|
12
|
+
Routing runs the same six checks every time, in this order. Each one is a filter or a preference within the set that survived the one before it.
|
|
13
13
|
|
|
14
|
-
1. **Eligibility** — enabled, capable of the lane, not quarantined, not exhausted, and allowed a model for the effort tier.
|
|
14
|
+
1. **Eligibility** — enabled, capable of the lane, not quarantined, not benched, not exhausted, and allowed a model for the effort tier.
|
|
15
15
|
2. **Forecast gate** — a pool projected at or above 90% of its 5-hour window is pushed to the back.
|
|
16
16
|
3. **5-hour headroom** — while any pool is below the near-limit line, only those are selectable.
|
|
17
|
-
4. **
|
|
18
|
-
5. **
|
|
17
|
+
4. **Free models** — while any surviving pool's model for this effort tier costs nothing, only those are selectable.
|
|
18
|
+
5. **Expiring-soon urgency** — while any pool's week or month is about to reset with quota left, only those are selectable.
|
|
19
|
+
6. **Preference inside the survivors** — an explicit tier assignment, then incumbency, then the highest effective surplus.
|
|
19
20
|
|
|
20
|
-
The rest of this page is those
|
|
21
|
+
The rest of this page is those six steps in detail.
|
|
21
22
|
|
|
22
23
|
## Eligibility
|
|
23
24
|
|
|
24
|
-
A pool has to be enabled, declare the lane, hold any capabilities the work requires, not be quarantined, not be exhausted (100% of its window used), and have an allowed model for the effort tier. A disabled pool simply is not a candidate. Nothing in the later steps can rescue a pool that fails here.
|
|
25
|
+
A pool has to be enabled, declare the lane, hold any capabilities the work requires, not be quarantined, not be soft-benched, not be exhausted (100% of its window used), and have an allowed model for the effort tier. A disabled pool simply is not a candidate. Nothing in the later steps can rescue a pool that fails here.
|
|
25
26
|
|
|
26
27
|
## Pace and surplus
|
|
27
28
|
|
|
@@ -31,13 +32,78 @@ Pace compares a pool with itself: **surplus = elapsed% − used%** of its own su
|
|
|
31
32
|
|
|
32
33
|
The rolling 5-hour window never paces — it only gates. A pool whose forecast is at or above 75% (`FIVE_HOUR_NEAR_LIMIT_PCT`) **and** above the share of that window already elapsed is tiered down: it is picked only when no eligible pool below the line exists for the lane. A pool at or above 90% (`BURST_BLOCK_PCT`) is left out of selection entirely, and that gate ignores the clock.
|
|
33
34
|
|
|
35
|
+
## Free models first
|
|
36
|
+
|
|
37
|
+
A model that costs nothing does not spend anyone's quota, so a pool holding one is ranked ahead of every metered pool. While any eligible pool's model for this effort tier is free, the metered pools are not selectable at all — they are not merely outscored, so the pick is not a close call that a surplus swing can flip.
|
|
38
|
+
|
|
39
|
+
Free-ness is a property of **(pool, effort tier)**, never of a pool: the same pool can hold a free model on `medium` and a paid one on `high`. It comes from the connector, which declares `"free": true` on the model profile that matches the selected model (`providers/*/connector.json`, `modelProfiles`); a discovered model whose name carries a standalone `free` segment — `openrouter/qwen:free` — counts as well.
|
|
40
|
+
|
|
41
|
+
The rest of the order is untouched. Free sits *below* the forecast gate and the 5-hour headroom tier, so a free pool at or above its 5-hour line still loses to a pool with headroom. It sits *above* expiring-soon urgency, which is a deliberate trade: while free work is available, a metered pool's expiring quota can expire unspent. And the ranking **among** metered pools is exactly what it was — the free tier is a constant across all of them, so their relative order is still decided by the existing keys.
|
|
42
|
+
|
|
43
|
+
The reason line says which rule applied, and names what it passed over:
|
|
44
|
+
|
|
45
|
+
```text
|
|
46
|
+
free pool first: opencode (free model opencode/union-alpha, 5h used 12.0%, 1 in flight) · metered pools ranked below free: codex 40.0, grok 12.3
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Soft bench: alive, but not producing
|
|
50
|
+
|
|
51
|
+
A free endpoint does not fail the way a metered one does. It has no usage meter, so it can never read as exhausted — when its free window ends the API starts erroring or falling silent rather than reporting 100%. The soft bench is the backstop.
|
|
52
|
+
|
|
53
|
+
Three failures count as a **strike** against a pool:
|
|
54
|
+
|
|
55
|
+
- **stall** — the worker wrote no output for longer than its silence threshold and was stopped;
|
|
56
|
+
- **provider** — the provider returned a server error;
|
|
57
|
+
- **empty output** — a free pool returned literally nothing. (An answer that has substance but the verifier judged thin stays a semantic failure and is not retried elsewhere; that is a verdict about the answer, not about the pool.)
|
|
58
|
+
|
|
59
|
+
Strikes are **consecutive**. The first one is recorded without taking the pool out of service. The second benches it for a 10-minute cooldown — the same re-probe window a quarantine uses — after which it returns automatically. The count survives that cooldown and is cleared only by a success, so a pool that stalls again straight after coming back is benched again immediately.
|
|
60
|
+
|
|
61
|
+
A bench is not a quarantine, and it changes nothing about auth. An upstream auth failure still quarantines, still spreads across a credential group, and its deadline is never shortened by a bench. A benched pool also keeps having its meter read, because it is coming back in ten minutes.
|
|
62
|
+
|
|
63
|
+
## Stall fallback
|
|
64
|
+
|
|
65
|
+
The silence clock bounds silence, not run time: it restarts on every byte a worker writes. A metered pool keeps the one-hour default. A **free** pool is stopped after the median wall time its own recorded runs at that (pool, effort) rung actually took — the `p50` in `bullswarm strategy`, and only once at least 3 runs have been recorded there — with a floor of 5 minutes. Stalled attempts are excluded from that median, so a pool cannot tighten its own threshold by stalling. `BULLSWARM_WORKER_SILENCE_SEC` overrides it for a probe.
|
|
66
|
+
|
|
67
|
+
When an attempt stalls, the run does not stop and the work is not lost:
|
|
68
|
+
|
|
69
|
+
- the worker's process is ended and **whatever it had already written stays on disk** — the retry writes a new `out-<action>-attempt-<n>` file beside it, never over it;
|
|
70
|
+
- the in-flight ledger entry is released, so the next pick sees the pool as idle;
|
|
71
|
+
- the same action is re-dispatched on the next eligible pool **in the same run**, and its reason line is prefixed with where it came from;
|
|
72
|
+
- the second consecutive stall benches the pool, so later actions in that run do not each wait out the threshold.
|
|
73
|
+
|
|
74
|
+
`--retry-attempts` (default 1) caps mechanical retries for metered failures. A
|
|
75
|
+
stall on a free pool (and its literally-empty-output provider reclassification)
|
|
76
|
+
does not spend that allowance: the dispatcher advances through eligible pools,
|
|
77
|
+
trying each pool at most once for the action, so the tried-set remains the
|
|
78
|
+
termination bound. A stall on a metered pool keeps the mechanical accounting.
|
|
79
|
+
|
|
80
|
+
```text
|
|
81
|
+
fallback from opencode after stall 300s · most-behind capable pool (surplus 40)
|
|
82
|
+
benched (stall, back at 2026-09-17T02:40:36.911Z): opencode
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
The run's events carry the same facts: `attempt.finished` gains `stalled`,
|
|
86
|
+
`partialOutput`, `silentSec` and `willRetry`, and a `pool.benched` event names
|
|
87
|
+
the pool, the reason, the strike count and the deadline. `bullswarm workflow
|
|
88
|
+
watch` renders `retrying on another pool` only when `willRetry` is true;
|
|
89
|
+
otherwise it says `no retry left`.
|
|
90
|
+
|
|
91
|
+
## Evidence steps keep normal routing
|
|
92
|
+
|
|
93
|
+
An evidence or acceptance step is never pushed onto the free pool: the free tier is switched off for it, and it routes on pace like any other action. It does get one preference — **a pool that wrote the work being judged is chosen last**. While any other eligible pool exists, the writer is not selected; when it is the only one left, it runs and the reason says so rather than failing the step.
|
|
94
|
+
|
|
95
|
+
```text
|
|
96
|
+
evidence step: normal routing (free tier not applied)
|
|
97
|
+
evidence step: only the writer pool answerer is eligible
|
|
98
|
+
```
|
|
99
|
+
|
|
34
100
|
## Expiring-soon urgency
|
|
35
101
|
|
|
36
|
-
A pool whose pacing window resets within 24 hours (weekly) or 3 days (monthly) is ranked on urgency — its surplus divided by the fraction of the window still to run — instead of on the surplus alone. While any urgent pool can still spend its quota, it is the only one selectable, which is how a pool with two hours left beats a pool with three days left. A pool
|
|
102
|
+
A pool whose pacing window resets within 24 hours (weekly) or 3 days (monthly) is ranked on urgency — its surplus divided by the fraction of the window still to run — instead of on the surplus alone. While any urgent pool can still spend its quota, it is the only one selectable, which is how a pool with two hours left beats a pool with three days left. A pool forecast at or above 95% of its pacing window *and* ahead of the window's own clock (forecast above the elapsed share) is `draining` and goes last; a pool at 96% with 98% of its month gone is spending at its own pace, not draining, and keeps its quota in play until the reset.
|
|
37
103
|
|
|
38
104
|
## In-flight load
|
|
39
105
|
|
|
40
|
-
Work already dispatched is charged against a pool before the next pick: each pool's effective surplus is its pace minus what its in-flight agents are expected to spend
|
|
106
|
+
Work already dispatched is charged against a pool before the next pick: each pool's effective surplus is its pace minus what its in-flight agents and the candidate are expected to spend. When the pacing window has a measured rate, each timed in-flight record is charged at `rate × remaining minutes` with no floor. A pool with no measured rate uses the flat `config.inflightPenaltyPct` tie-breaker in `~/.bullswarm/state.json` (default 3; `0` turns it off); a measured pool uses that value only for an in-flight record whose remaining duration is unknown. A busier pool therefore yields to a quieter one at similar pace.
|
|
41
107
|
|
|
42
108
|
## Assignments and incumbency
|
|
43
109
|
|
|
@@ -57,7 +123,12 @@ A relayed credential fails upstream, not in the CLI. When a provider's event str
|
|
|
57
123
|
|
|
58
124
|
## How the decision shows its work
|
|
59
125
|
|
|
60
|
-
`bullswarm pools` prints one line per pool: `cost=`, `lanes=`, the meter it is paced from, `surplus=`, `inflight=`, the 5-hour column as `5h=<reading>%-><projected>%` with `(<n>% elapsed)`, then `ready`, `disabled`, `QUARANTINED until …`, `NEAR-5H-LIMIT`, `BURST-GATED`, or `resets in … EXPIRING-SOON urgency=<n>`.
|
|
126
|
+
`bullswarm pools` prints one line per pool: `cost=`, `lanes=`, the meter it is paced from, `surplus=`, `inflight=`, the 5-hour column as `5h=<reading>%-><projected>%` with `(<n>% elapsed)`, `free=<model>` when its model costs nothing, then `ready`, `disabled`, `QUARANTINED until …`, `BENCHED until … (<reason>, <n> strikes)`, `NEAR-5H-LIMIT`, `BURST-GATED`, or `resets in … EXPIRING-SOON urgency=<n>`. A pool carrying an uncounted-out strike prints `strikes=<n>(<reason>)` beside `ready`. `pools` names no lane and therefore no effort tier, so when free-ness differs per tier the column names each one — `free=medium:opencode/union-alpha`. A stale meter held after a failed poll is marked `[stale · <status-or-kind>, retry in <time>]`; `pools --json` carries the same `meterError` and `meterHoldUntil` fields.
|
|
127
|
+
|
|
128
|
+
```text
|
|
129
|
+
answerer cost=3 lanes=analyze/build/chore unmetered surplus=0 inflight=0 ready
|
|
130
|
+
opencode cost=1 lanes=analyze/build/chore unmetered surplus=0 inflight=0 free=opencode/union-alpha BENCHED until 10:40:36 AM (stall, 2 strikes)
|
|
131
|
+
```
|
|
61
132
|
|
|
62
133
|
```bash
|
|
63
134
|
# every pool, with its meter, pace, load, and quarantine state
|
|
@@ -92,7 +163,7 @@ forecast: inflight=0 5h ?%->?% expected=5.67m rate=unmeasured basis=bootstrap
|
|
|
92
163
|
|
|
93
164
|
Grok won because it was the only pool left: this `analyze` task resolves to the medium effort tier, whose allow-list names a model only on grok and codex, and codex is disabled — eligibility runs before any pace comparison, so the +35.6 on `claude-code:wati` never entered the race.
|
|
94
165
|
|
|
95
|
-
When a pool is passed over, the reason says so in the same line — `skipped near 5h limit (projected): claude-code:wati 88.1% (92.3% elapsed)`, `forecast-gated at/above 90%: …`, `expiring but draining (forecast >= 95%):
|
|
166
|
+
When a pool is passed over, the reason says so in the same line — `skipped near 5h limit (projected): claude-code:wati 88.1% (92.3% elapsed)`, `forecast-gated at/above 90%: …`, `expiring but draining (forecast >= 95% and past its clock): grok 99.5% (98.8% elapsed)`, or `preferred over busier: … (2 in flight)`.
|
|
96
167
|
|
|
97
168
|
## Next steps
|
|
98
169
|
|
package/docs/reference/cli.md
CHANGED
|
@@ -522,7 +522,7 @@ Record known subscription pricing for a pool so refresh's value-multiple math is
|
|
|
522
522
|
```bash
|
|
523
523
|
# Record plan economics, then declare a refill date a wallet does not report.
|
|
524
524
|
bullswarm strategy set-subscription claude --plan max --monthly-usd 200 --included-usd 1000
|
|
525
|
-
bullswarm strategy set-subscription
|
|
525
|
+
bullswarm strategy set-subscription opencode --resets-at 2026-09-17T01:46:01Z
|
|
526
526
|
```
|
|
527
527
|
|
|
528
528
|
| Flag | Meaning | Default |
|
|
@@ -619,7 +619,7 @@ Write a commented provider directory: a `provider.mjs` skeleton showing every ex
|
|
|
619
619
|
|
|
620
620
|
```bash
|
|
621
621
|
# Scaffold a reseller that runs through OpenCode.
|
|
622
|
-
bullswarm provider scaffold relay --from
|
|
622
|
+
bullswarm provider scaffold relay --from opencode
|
|
623
623
|
```
|
|
624
624
|
|
|
625
625
|
| Flag | Meaning | Default |
|
|
@@ -679,6 +679,8 @@ Without a program the command refuses (exit 2, nothing launched) unless `--scout
|
|
|
679
679
|
|
|
680
680
|
Launches independently by default. `--resume <shortId|runId>` resumes a V2 run and is mutually exclusive with new goal text.
|
|
681
681
|
|
|
682
|
+
A duplicate launch is refused before anything is validated or started: when an ongoing run in the same `--cwd` already has this goal text, the command exits 2 with that run's `shortId`, its age, and the command to watch it. `--json` prints `{"error":"duplicate-goal","shortId":…,"runId":…,"startedAt":…,"next":{"watch":…,"again":…}}`. Pass `--again` to start the second copy anyway.
|
|
683
|
+
|
|
682
684
|
```bash
|
|
683
685
|
# Caller-planned launch that follows progress until terminal.
|
|
684
686
|
bullswarm workflow goal "1. Fix src/parser.js. 2. Update docs." --cwd . --program plan.json --watch
|
|
@@ -711,6 +713,7 @@ bullswarm workflow goal "1. Fix src/parser.js. 2. Update docs." --cwd . --progra
|
|
|
711
713
|
| `--retry-attempts <0..3>` | bounded retries for mechanical failures only; semantic evidence never auto-repairs | `1` |
|
|
712
714
|
| `--resume <shortId\|runId>` | resume a V2 autonomous run; old autonomous runs fail closed before dispatch | starts a new goal |
|
|
713
715
|
| `--detach` | rarely needed — explicitly requests the default independent-launch behavior; cannot combine with `--watch` | the default launch already detaches |
|
|
716
|
+
| `--again` | start another copy even when an ongoing run already has the same goal text in the same `--cwd`; only a new launch is checked, never `--resume` or the internal `--request` relaunch | off (a duplicate of an ongoing goal is refused) |
|
|
714
717
|
|
|
715
718
|
Workers keep their edits even when their action fails. Failed dependencies skip downstream actions and independent branches finish. Saved V2 runs keep their original completion and isolation policy when resumed.
|
|
716
719
|
|
|
@@ -116,7 +116,7 @@ A provider is a directory with `connector.json` and/or `provider.mjs`. Three tie
|
|
|
116
116
|
|
|
117
117
|
```bash
|
|
118
118
|
# Write a local provider directory (default ~/.bullswarm/providers/<name>/).
|
|
119
|
-
bullswarm provider scaffold relay --from
|
|
119
|
+
bullswarm provider scaffold relay --from opencode
|
|
120
120
|
bullswarm provider validate relay
|
|
121
121
|
bullswarm provider probe relay
|
|
122
122
|
```
|
|
@@ -16,7 +16,7 @@ A provider is a directory holding a `connector.json`, a `provider.mjs`, or both.
|
|
|
16
16
|
| Tier | Directory | Loaded when | Members |
|
|
17
17
|
|---|---|---|---|
|
|
18
18
|
| first-class | `src/providers/<name>/` | always | `claude-code`, `codex`, `grok`, `echo` |
|
|
19
|
-
| contrib | `providers/contrib/<name>/` | listed in `~/.bullswarm/providers.json` | `command-code`, `
|
|
19
|
+
| contrib | `providers/contrib/<name>/` | listed in `~/.bullswarm/providers.json` | `command-code`, `opencode` |
|
|
20
20
|
| local | `~/.bullswarm/providers/<name>/` | always | your own, never in the repository |
|
|
21
21
|
|
|
22
22
|
First-class and contrib providers ship in the package. A contrib provider loads only on a machine that lists it:
|
|
@@ -80,7 +80,7 @@ Every method receives the same `ctx`:
|
|
|
80
80
|
|
|
81
81
|
`readUsage` also receives `subscription`: the pool's entry in `state.strategy.subscriptions`, or `null`. It carries `includedValueUsd`, `quotaWindow`, `resetsAt`, `plan`, and `monthlyPriceUsd`.
|
|
82
82
|
|
|
83
|
-
`templates` exists so a provider can build on a shipped CLI without copying its template: a reseller of OpenCode access clones `templates.
|
|
83
|
+
`templates` exists so a provider can build on a shipped CLI without copying its template: a reseller of OpenCode access clones `templates.opencode`.
|
|
84
84
|
|
|
85
85
|
## The kit
|
|
86
86
|
|
|
@@ -143,7 +143,7 @@ These are all the pool fields a provider may set, and the part of the core that
|
|
|
143
143
|
|
|
144
144
|
## Example: a reseller named relay
|
|
145
145
|
|
|
146
|
-
Relay sells OpenCode access through two accounts. The provider clones the shipped `
|
|
146
|
+
Relay sells OpenCode access through two accounts. The provider clones the shipped `opencode` template once per account, pins each account's model and reasoning variants, groups both under one credential so a usage limit on one benches its sibling, and reports spend from a wallet:
|
|
147
147
|
|
|
148
148
|
```js
|
|
149
149
|
// ~/.bullswarm/providers/relay/provider.mjs
|
|
@@ -152,7 +152,7 @@ export const displayName = 'Relay';
|
|
|
152
152
|
|
|
153
153
|
export function connectors({ kit, templates }) {
|
|
154
154
|
const accounts = [{ id: 'a', models: ['gpt-5.6-sol'] }, { id: 'b', models: ['gpt-5.6-sol'] }];
|
|
155
|
-
return accounts.map((acc, i) => kit.clonePool(templates.
|
|
155
|
+
return accounts.map((acc, i) => kit.clonePool(templates.opencode, {
|
|
156
156
|
name: i === 0 ? 'relay' : `relay:${acc.id}`,
|
|
157
157
|
model: `${acc.id}/gpt-5.6-sol`,
|
|
158
158
|
env: { OPENCODE_CONFIG_CONTENT: kit.opencodeVariants(acc.id, acc.models) },
|
|
@@ -187,7 +187,7 @@ The `usedUsd = 12.5` line is a placeholder: a real `readUsage` fetches the walle
|
|
|
187
187
|
|
|
188
188
|
```bash
|
|
189
189
|
# Author a local provider from a shipped template, then check shape and spawn.
|
|
190
|
-
bullswarm provider scaffold relay --from
|
|
190
|
+
bullswarm provider scaffold relay --from opencode
|
|
191
191
|
bullswarm provider validate relay
|
|
192
192
|
bullswarm provider probe relay --json
|
|
193
193
|
```
|
package/docs/workflow-design.md
CHANGED
|
@@ -175,7 +175,7 @@ bullswarm workflow · route-review · run wf-a1b2c3
|
|
|
175
175
|
▐ phase 2/2 · review ⏳ running
|
|
176
176
|
⟡ per-file-review[0/47] grok 31.0s … verifying
|
|
177
177
|
✓ per-file-review[1/47] command-code 28.9s ok
|
|
178
|
-
✗ per-file-review[2/47]
|
|
178
|
+
✗ per-file-review[2/47] opencode 40.1s fail · announcement without substance
|
|
179
179
|
↳ escalate → codex
|
|
180
180
|
⋈ per-file-review[3/47] — — quarantined pool, waiting
|
|
181
181
|
|
|
@@ -69,6 +69,25 @@ export type PaneActions = {
|
|
|
69
69
|
close: () => void
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
/** The single-row Usage header, including the oldest failed meter reason. */
|
|
73
|
+
export function poolsHeaderText(
|
|
74
|
+
pools: readonly BullswarmPool[],
|
|
75
|
+
nowMs: number,
|
|
76
|
+
nameOf: (pool: string | null) => string = pool => pool ?? '',
|
|
77
|
+
): string {
|
|
78
|
+
const sampled = pools
|
|
79
|
+
.filter(p => !!p.capturedAt)
|
|
80
|
+
.sort((a, b) => Date.parse(a.capturedAt!) - Date.parse(b.capturedAt!))
|
|
81
|
+
const oldest = sampled[0] ?? null
|
|
82
|
+
const oldestError = sampled.find(p => !!p.meterError) ?? null
|
|
83
|
+
const shown = oldestError ?? oldest
|
|
84
|
+
if (!shown) return 'Pools · no meter snapshot yet'
|
|
85
|
+
const age = ageOf(shown.capturedAt, nowMs) || '0m'
|
|
86
|
+
return oldestError
|
|
87
|
+
? `Pools · ${nameOf(shown.name)} sampled ${age} ago · ${oldestError.meterError}`
|
|
88
|
+
: `Pools · sampled ${age} ago`
|
|
89
|
+
}
|
|
90
|
+
|
|
72
91
|
/** Below this many body rows the pane draws its compact layout (a phone terminal, a short inline pane). */
|
|
73
92
|
export const COMPACT_ROWS = 20
|
|
74
93
|
/** Blank rows drawn past the footer so the engine reports the pane's true height. */
|
|
@@ -253,13 +272,12 @@ export function paneView(
|
|
|
253
272
|
const blank = (key: string) => <Text key={key}> </Text>
|
|
254
273
|
|
|
255
274
|
if (model.poolsPage) {
|
|
256
|
-
const sampled = model.pools.map(p => p.capturedAt).filter((x): x is string => !!x).sort()[0] ?? null
|
|
257
275
|
const header: RenderElement[] = [
|
|
258
276
|
<Text key="h0" wrap="truncate-end">
|
|
259
277
|
<Text bold color="cyan">
|
|
260
278
|
Pools
|
|
261
279
|
</Text>
|
|
262
|
-
<Text dimColor>{
|
|
280
|
+
<Text dimColor>{poolsHeaderText(model.pools, model.nowMs, nameOf).slice('Pools'.length)}</Text>
|
|
263
281
|
</Text>,
|
|
264
282
|
]
|
|
265
283
|
if (!compact) header.push(nav)
|
|
@@ -33,6 +33,8 @@ export function parsePools(stdout: string): BullswarmPool[] {
|
|
|
33
33
|
fiveHourUsedPct: num(p.fiveHourUsedPct),
|
|
34
34
|
costRank: num(p.costRank),
|
|
35
35
|
meterSource: str(p.meterSource),
|
|
36
|
+
meterError: str(p.meterError),
|
|
37
|
+
meterHoldUntil: num(p.meterHoldUntil),
|
|
36
38
|
incumbentLane: Array.isArray(p.incumbentLane)
|
|
37
39
|
? p.incumbentLane.filter((l): l is string => typeof l === 'string')
|
|
38
40
|
: [],
|
|
@@ -166,6 +166,10 @@ export type BullswarmPool = {
|
|
|
166
166
|
fiveHourUsedPct: number | null
|
|
167
167
|
costRank: number | null
|
|
168
168
|
meterSource: string | null
|
|
169
|
+
/** A short provider error reason while a stale meter is held. */
|
|
170
|
+
meterError: string | null
|
|
171
|
+
/** Epoch milliseconds at which a stale-meter hold may be retried. */
|
|
172
|
+
meterHoldUntil: number | null
|
|
169
173
|
incumbentLane: readonly string[]
|
|
170
174
|
quarantine: { until: number; reason: string; kind: string } | null
|
|
171
175
|
}
|
package/package.json
CHANGED
|
@@ -7,7 +7,7 @@ Bullswarm loads one only when your machine opts in. The providers under
|
|
|
7
7
|
| provider | what it runs | meter |
|
|
8
8
|
|---|---|---|
|
|
9
9
|
| `command-code` | the Command Code CLI (`command-code -p`) | monthly credits, 5-hour and weekly windows from the Command Code billing API |
|
|
10
|
-
| `
|
|
10
|
+
| `opencode` | the OpenCode CLI (`opencode run`) | none; also the template reseller providers clone as `ctx.templates.opencode` |
|
|
11
11
|
|
|
12
12
|
## Enabling one
|
|
13
13
|
|
|
@@ -8,6 +8,7 @@ import os from 'node:os';
|
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
|
|
10
10
|
import { MeterError } from '../../../src/provider-kit.js';
|
|
11
|
+
import { retryAfterMsFromHeaders } from '../../../src/meters/framework.js';
|
|
11
12
|
|
|
12
13
|
export const name = 'command-code';
|
|
13
14
|
export const displayName = 'Command Code';
|
|
@@ -39,8 +40,8 @@ const PLAN_CREDITS = {
|
|
|
39
40
|
};
|
|
40
41
|
|
|
41
42
|
export class CommandCodeMeterError extends MeterError {
|
|
42
|
-
constructor(message, code) {
|
|
43
|
-
super(message, code); // no_auth | http | parse | network
|
|
43
|
+
constructor(message, code, options = {}) {
|
|
44
|
+
super(message, code, options); // no_auth | http | parse | network
|
|
44
45
|
this.name = 'CommandCodeMeterError';
|
|
45
46
|
}
|
|
46
47
|
}
|
|
@@ -176,13 +177,14 @@ async function fetchJson(url, key) {
|
|
|
176
177
|
} catch (err) {
|
|
177
178
|
throw new CommandCodeMeterError(`Network error reaching Command Code billing: ${err.message}`, 'network');
|
|
178
179
|
}
|
|
180
|
+
const retryAfterMs = retryAfterMsFromHeaders(res.headers);
|
|
179
181
|
let body = null;
|
|
180
182
|
try {
|
|
181
183
|
body = await res.json();
|
|
182
184
|
} catch {
|
|
183
185
|
/* leave null */
|
|
184
186
|
}
|
|
185
|
-
return { status: res.status, body };
|
|
187
|
+
return { status: res.status, body, retryAfterMs };
|
|
186
188
|
}
|
|
187
189
|
|
|
188
190
|
/**
|
|
@@ -197,10 +199,16 @@ export async function readUsage(pool = name, ctx = {}) {
|
|
|
197
199
|
|
|
198
200
|
const creditsRes = await fetchJson(`${base}${CREDITS_PATH}`, key);
|
|
199
201
|
if (creditsRes.status === 401 || creditsRes.status === 403) {
|
|
200
|
-
throw new CommandCodeMeterError(`Command Code credits HTTP ${creditsRes.status}`, 'no_auth'
|
|
202
|
+
throw new CommandCodeMeterError(`Command Code credits HTTP ${creditsRes.status}`, 'no_auth', {
|
|
203
|
+
status: creditsRes.status,
|
|
204
|
+
retryAfterMs: creditsRes.retryAfterMs,
|
|
205
|
+
});
|
|
201
206
|
}
|
|
202
207
|
if (creditsRes.status < 200 || creditsRes.status >= 300) {
|
|
203
|
-
throw new CommandCodeMeterError(`Command Code credits HTTP ${creditsRes.status}`, 'http'
|
|
208
|
+
throw new CommandCodeMeterError(`Command Code credits HTTP ${creditsRes.status}`, 'http', {
|
|
209
|
+
status: creditsRes.status,
|
|
210
|
+
retryAfterMs: creditsRes.retryAfterMs,
|
|
211
|
+
});
|
|
204
212
|
}
|
|
205
213
|
|
|
206
214
|
const credits = parseCommandCodeCredits(creditsRes.body);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"name": "
|
|
2
|
+
"name": "opencode",
|
|
3
3
|
"bin": "opencode",
|
|
4
4
|
"configDirs": ["~/.config/opencode"],
|
|
5
5
|
"spawn": {
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
{ "match": "gpt-5\\.6-sol$", "tier": "high", "qualityRank": 6, "autoRecommend": true },
|
|
47
47
|
{ "match": "(?:opus|gpt-5\\.5$)", "tier": "high", "qualityRank": 5 },
|
|
48
48
|
{ "match": "(?:sonnet|terra|gpt-5\\.4$|deepseek-v4)", "tier": "medium", "qualityRank": 4 },
|
|
49
|
+
{ "match": "(?:union-alpha|-free$)", "tier": "medium", "qualityRank": 3, "free": true },
|
|
49
50
|
{ "match": "(?:haiku|luna|(?:^|[/.-])mini(?:$|[/.-])|flash|free)", "tier": "low", "qualityRank": 2 }
|
|
50
51
|
],
|
|
51
52
|
"subscription": { "plan": null, "monthlyPriceUsd": null, "includedValueUsd": null, "quotaWindow": null },
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
// bullswarm contrib provider:
|
|
1
|
+
// bullswarm contrib provider: opencode — the generic OpenCode CLI pool.
|
|
2
2
|
// connector.json is the whole pool; there is no meter. It is also the
|
|
3
|
-
// template a reseller provider clones through `ctx.templates.
|
|
3
|
+
// template a reseller provider clones through `ctx.templates.opencode`
|
|
4
4
|
// (see docs/reference/providers.md).
|
|
5
5
|
|
|
6
|
-
export const name = '
|
|
6
|
+
export const name = 'opencode';
|
|
7
7
|
export const displayName = 'OpenCode';
|
|
@@ -40,7 +40,7 @@ second login), skip most of this: clone the shipped template.
|
|
|
40
40
|
|
|
41
41
|
```bash
|
|
42
42
|
bullswarm provider scaffold <name>
|
|
43
|
-
bullswarm provider scaffold <name> --from
|
|
43
|
+
bullswarm provider scaffold <name> --from opencode
|
|
44
44
|
```
|
|
45
45
|
|
|
46
46
|
This writes a commented `provider.mjs` with every export. `--from` copies
|
package/src/cli.js
CHANGED
|
@@ -4,15 +4,15 @@ import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync } from
|
|
|
4
4
|
import { join, resolve } from 'node:path';
|
|
5
5
|
import { homedir, tmpdir } from 'node:os';
|
|
6
6
|
import {
|
|
7
|
-
expiringSoonView, fiveHourElapsedPct, formatResetsIn, pickPool,
|
|
7
|
+
expiringSoonView, fiveHourElapsedPct, formatResetsIn, isBenched, pickPool,
|
|
8
8
|
} from './lib/route.js';
|
|
9
9
|
import { argvWithModel, watchOnce } from './lib/watch.js';
|
|
10
10
|
import {
|
|
11
11
|
isReasoningLevel, REASONING_DEFAULT, REASONING_LEVELS, resolveReasoningLevel,
|
|
12
12
|
} from './lib/reasoning.js';
|
|
13
13
|
import {
|
|
14
|
-
loadState, quarantinePool, quarantineUpstreamSiblings,
|
|
15
|
-
assertDepthAllowed, childDepthEnv, upstreamGroupOf,
|
|
14
|
+
loadState, quarantinePool, quarantineUpstreamSiblings, sweepBenches, sweepQuarantines,
|
|
15
|
+
updateState, assertDepthAllowed, childDepthEnv, upstreamGroupOf,
|
|
16
16
|
} from './lib/state.js';
|
|
17
17
|
import { buildPools, buildPoolsLive } from './lib/config.js';
|
|
18
18
|
import { getAllMeterReadings } from './meters/registry.js';
|
|
@@ -73,6 +73,23 @@ export function parseArgs(argv) {
|
|
|
73
73
|
|
|
74
74
|
// --- pools ----------------------------------------------------------------
|
|
75
75
|
|
|
76
|
+
/** The bracketed meter source shown by `bullswarm pools`. */
|
|
77
|
+
export function meterSourceLabel(pool, nowMs = Date.now()) {
|
|
78
|
+
const source = pool?.meterSource ?? 'none';
|
|
79
|
+
if (source === 'stale' && pool?.meterError) {
|
|
80
|
+
const holdUntil = Number(pool.meterHoldUntil);
|
|
81
|
+
const remainingMs = holdUntil - nowMs;
|
|
82
|
+
const retry = Number.isFinite(remainingMs) && remainingMs > 0
|
|
83
|
+
? `, retry in ${remainingMs < 60_000
|
|
84
|
+
? `${Math.ceil(remainingMs / 1000)}s`
|
|
85
|
+
: `${Math.ceil(remainingMs / 60_000)}m`}`
|
|
86
|
+
: '';
|
|
87
|
+
return `stale · ${pool.meterError}${retry}`;
|
|
88
|
+
}
|
|
89
|
+
const resetTag = pool?.resetSource === 'declared' ? ' declared-reset' : '';
|
|
90
|
+
return `${source}${resetTag}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
76
93
|
async function cmdPools(opts) {
|
|
77
94
|
const now = Date.now();
|
|
78
95
|
const { state, pools } = await buildPoolsLive(getBullswarmDir(), now, {
|
|
@@ -84,13 +101,26 @@ async function cmdPools(opts) {
|
|
|
84
101
|
// persists when it actually released something: `pools` is an observation
|
|
85
102
|
// command and must not rewrite state.json just for being run.
|
|
86
103
|
let released = [];
|
|
104
|
+
let unbenched = [];
|
|
87
105
|
updateState(getBullswarmDir(), (fresh) => {
|
|
88
106
|
released = sweepQuarantines(fresh, now);
|
|
89
|
-
|
|
107
|
+
// A soft bench expires on the same terms and by the same rule (S6): the
|
|
108
|
+
// cooldown passing returns the pool to service, and the strike count stays
|
|
109
|
+
// so a stall immediately afterwards is still its second in a row.
|
|
110
|
+
unbenched = sweepBenches(fresh, now);
|
|
111
|
+
return released.length > 0 || unbenched.length > 0;
|
|
90
112
|
});
|
|
91
113
|
if (released.length && !opts.json) {
|
|
92
114
|
console.error(`quarantine expired, returned to service: ${released.join(', ')}`);
|
|
93
115
|
}
|
|
116
|
+
if (unbenched.length && !opts.json) {
|
|
117
|
+
console.error(`bench expired, returned to service: ${unbenched.join(', ')}`);
|
|
118
|
+
}
|
|
119
|
+
// The sweep above wrote to state, not to the pool views built before it, so
|
|
120
|
+
// an expired bench would still print as BENCHED. Re-read what the sweep left.
|
|
121
|
+
for (const p of pools) {
|
|
122
|
+
if (p.bench && !isBenched(p, now)) p.bench = { ...p.bench, until: null };
|
|
123
|
+
}
|
|
94
124
|
// Current cross-process load, from the shared ledger rather than this
|
|
95
125
|
// process's own memory: work another Bullswarm started still shows here —
|
|
96
126
|
// plus the spend rates that turn that load into a projected utilization.
|
|
@@ -108,10 +138,9 @@ async function cmdPools(opts) {
|
|
|
108
138
|
const window = p.pacingWindow && p.elapsedPct != null ? `${p.pacingWindow} ` : '';
|
|
109
139
|
// A window whose end the operator declared (the provider reported none)
|
|
110
140
|
// is paced from that date and says so; the used% is still the provider's.
|
|
111
|
-
const resetTag = p.resetSource === 'declared' ? ' declared-reset' : '';
|
|
112
141
|
const meter = src === 'none'
|
|
113
142
|
? 'unmetered'
|
|
114
|
-
: `${window}used ${p.usedPct ?? '?'}% elapsed ${p.elapsedPct ?? '?'}% [${
|
|
143
|
+
: `${window}used ${p.usedPct ?? '?'}% elapsed ${p.elapsedPct ?? '?'}% [${meterSourceLabel(p, now)}]`;
|
|
115
144
|
const burst = p.burstGate ? ' BURST-GATED' : '';
|
|
116
145
|
// 5h is a gate, never a pace (doctrine M3): show the reading and whether
|
|
117
146
|
// routing now deprioritizes this pool for it. When in-flight work makes
|
|
@@ -137,13 +166,32 @@ async function cmdPools(opts) {
|
|
|
137
166
|
? ` resets in ${formatResetsIn(expiring.minutesToReset)} EXPIRING-SOON`
|
|
138
167
|
+ ` urgency=${Math.round(expiring.urgency)}`
|
|
139
168
|
: '';
|
|
169
|
+
// R12: the model this pool would run costs nothing, so routing puts it
|
|
170
|
+
// ahead of every metered pool while it is healthy. `pools` names no lane
|
|
171
|
+
// and therefore no effort tier, and free-ness is per tier — so the tiers
|
|
172
|
+
// that hold a free model are named one by one rather than collapsed into a
|
|
173
|
+
// single claim that would be untrue on the others.
|
|
174
|
+
const freeTiers = Object.entries(p.freeTiers ?? {});
|
|
175
|
+
const free = p.free === true
|
|
176
|
+
? ` free=${p.freeModel ?? '?'}`
|
|
177
|
+
: freeTiers.length
|
|
178
|
+
? ` free=${freeTiers.map(([tier, model]) => `${tier}:${model}`).join(',')}`
|
|
179
|
+
: '';
|
|
180
|
+
// A soft bench (S6) is not a quarantine: the pool is alive but was not
|
|
181
|
+
// producing. A first strike is counted without taking it out, so say that
|
|
182
|
+
// too — otherwise a pool one stall from the bench looks perfectly healthy.
|
|
183
|
+
const strikes = p.bench && !isBenched(p, now) && Number(p.bench.count ?? 0) > 0
|
|
184
|
+
? ` strikes=${p.bench.count}(${p.bench.reason ?? '?'})`
|
|
185
|
+
: '';
|
|
140
186
|
const status = !p.enabled
|
|
141
187
|
? 'disabled'
|
|
142
188
|
: p.quarantine
|
|
143
189
|
? `QUARANTINED until ${new Date(p.quarantine.until).toLocaleTimeString()} (${p.quarantine.reason})`
|
|
144
|
-
:
|
|
190
|
+
: isBenched(p, now)
|
|
191
|
+
? `BENCHED until ${new Date(Number(p.bench.until)).toLocaleTimeString()} (${p.bench.reason ?? '?'}, ${p.bench.count ?? '?'} strikes)`
|
|
192
|
+
: `ready${burst}${nearLimit}${strikes}`;
|
|
145
193
|
console.log(
|
|
146
|
-
`${p.name.padEnd(14)} cost=${p.costRank} lanes=${p.lanes.join('/')} ${meter} surplus=${p.pace ?? '-'} inflight=${p.inflight?.count ?? 0}${fiveHour} ${status}${expiringNote}`,
|
|
194
|
+
`${p.name.padEnd(14)} cost=${p.costRank} lanes=${p.lanes.join('/')} ${meter} surplus=${p.pace ?? '-'} inflight=${p.inflight?.count ?? 0}${fiveHour}${free} ${status}${expiringNote}`,
|
|
147
195
|
);
|
|
148
196
|
}
|
|
149
197
|
return 0;
|