bullswarm 0.28.0 → 0.28.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 +86 -3
- package/README.md +10 -1
- package/package.json +1 -1
- package/skill/references/operations.md +11 -2
- package/src/cli.js +6 -1
- package/src/help.js +5 -4
- package/src/lib/config.js +44 -6
- package/src/lib/forecast.js +3 -2
- package/src/lib/route.js +36 -19
- package/src/lib/spend.js +40 -20
- package/src/lib/strategy.js +6 -0
- package/src/meters/framework.js +82 -6
- package/src/strategy-cli.js +33 -3
- package/src/workflow/runs-cli.js +5 -1
- package/src/workflow/v2-dispatch.js +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,87 @@
|
|
|
1
1
|
# bullswarm changelog
|
|
2
2
|
|
|
3
|
+
## 0.28.1 — summary bytes on the wire, monthly pacing
|
|
4
|
+
|
|
5
|
+
- `workflow runs result <id> --summary` was budgeted by `fitResultSummary`
|
|
6
|
+
against compact `JSON.stringify(summary)` (`RESULT_SUMMARY_BYTE_BUDGET =
|
|
7
|
+
4096` in `src/workflow/v2-outcome.js`) but `jsonOut` printed
|
|
8
|
+
`JSON.stringify(obj, null, 2)`, so the bytes on the wire exceeded the
|
|
9
|
+
budget. `--summary` now prints compact single-line JSON
|
|
10
|
+
(`JSON.stringify(obj)` in `src/workflow/runs-cli.js`); `--json` without
|
|
11
|
+
`--summary` still pretty-prints the full envelope, and `--summary --json`
|
|
12
|
+
stays identical to `--summary`. Measured on
|
|
13
|
+
`tests/fixtures/real-result-ze5xz2.json` through the summariser / CLI:
|
|
14
|
+
compact `--summary` is 3,786 bytes
|
|
15
|
+
(`tests/workflow-result-summary.test.js` prints `result-summary size:
|
|
16
|
+
full=57141 summary=3786`; same figure as
|
|
17
|
+
`Buffer.byteLength(JSON.stringify(summarizeV2Result(fixture)))`); the
|
|
18
|
+
full envelope as `--json` prints it is 60,709 bytes
|
|
19
|
+
(`JSON.stringify(envelope, null, 2)` plus the trailing newline
|
|
20
|
+
`console.log` adds — `tests/workflow-result-summary.test.js` prints
|
|
21
|
+
`result-summary cli: prettyFull=60709`).
|
|
22
|
+
|
|
23
|
+
- Routing paced every pool by `windows.seven_day ?? windows.monthly`
|
|
24
|
+
(`paceSnapshot` in `src/meters/framework.js`), so command-code — whose real
|
|
25
|
+
budget is a monthly credit allocation — was paced by its weekly rate-limit
|
|
26
|
+
window. Live meter, captured 2026-09-09T10:45:28Z and evaluated at
|
|
27
|
+
2026-09-09T11:09:44.982Z: weekly `used 73.1% elapsed 91.8% surplus +18.7`
|
|
28
|
+
against monthly `used 79.4% elapsed 75.3% surplus -4.1`, with 14.43 of 70
|
|
29
|
+
credits left for the 7.66 days to the 2026-09-17T03:06:55Z reset. Routing
|
|
30
|
+
therefore called it "the most-behind capable pool" and kept sending it work
|
|
31
|
+
while its monthly budget was already 4.1 points overspent. Pacing is now per
|
|
32
|
+
pool: `pacingWindowFor({connector, subscription})` resolves
|
|
33
|
+
`state.strategy.subscriptions[pool].quotaWindow`, then
|
|
34
|
+
`connector.subscription.quotaWindow`, normalised to `weekly` | `monthly` |
|
|
35
|
+
`null` (any other label — including a pre-0.28.1 free-text one — is ignored
|
|
36
|
+
for pacing and keeps the old weekly-first order). `paceSnapshot(snapshot,
|
|
37
|
+
nowMs, {pacingWindow})` takes `monthly` → `windows.monthly ??
|
|
38
|
+
windows.seven_day`, `weekly`/`null` → `windows.seven_day ??
|
|
39
|
+
windows.monthly`, and returns `pacingWindow` naming the window actually
|
|
40
|
+
used. `src/lib/config.js` `buildPools` resolves the choice once per pool
|
|
41
|
+
(where the connector and the state both are) and re-paces `usedPct`,
|
|
42
|
+
`elapsedPct`, `pace` and `paceResetsAt` off `reading.windows`, so cache,
|
|
43
|
+
stale and live readings are paced identically; the pool view carries
|
|
44
|
+
`pacingWindow`. The 5h gate is untouched: `command-code` still gates on 5h
|
|
45
|
+
`25.2%`. Connectors already declared this — `command-code` and the kaihk
|
|
46
|
+
pools `monthly`, `claude-code`/`codex`/`grok` `weekly`; nothing read it for
|
|
47
|
+
pacing before.
|
|
48
|
+
- The spend model follows the pacing window. `WINDOW_KEYS` (framework.js)
|
|
49
|
+
gains `monthly: {snapshot: 'monthly', history: 'monthly', windowMs: null}`,
|
|
50
|
+
and `rateForWindow` (`src/lib/spend.js`) derives the bootstrap window start
|
|
51
|
+
with `meta.windowMs ?? monthlyWindowMs(resetsAtMs)` — the calendar month
|
|
52
|
+
ending at the provider's `resets_at` (M2), never an assumed 30 days.
|
|
53
|
+
`attachSpend` now writes `pool.spend.monthly` and `pool.projectedMonthlyPct`
|
|
54
|
+
next to the fiveHour/weekly fields, plus `pool.spend.pacing = {window,
|
|
55
|
+
ratePerMinute, source, samples}` and `pool.projectedPacingPct` for the
|
|
56
|
+
window that paces the pool (default `weekly`, so a pool that declares
|
|
57
|
+
nothing keeps its old numbers). `inflightLoad` (`src/lib/route.js`) charges
|
|
58
|
+
the in-flight penalty from `spend.pacing?.ratePerMinute ??
|
|
59
|
+
spend.weekly?.ratePerMinute` with that rate's own source label, so the
|
|
60
|
+
surplus and the penalty are measured in the same window; candidate rows gain
|
|
61
|
+
`pacingWindow` and `projectedPacingPct` beside the unchanged
|
|
62
|
+
`projectedWeeklyPct`.
|
|
63
|
+
- Operator control and display. `bullswarm strategy set-subscription <pool>
|
|
64
|
+
--quota-window <weekly|monthly>` now selects the window that paces routing
|
|
65
|
+
(help text in `src/help.js`) and validates it: anything else exits 2 with
|
|
66
|
+
`--quota-window must be weekly or monthly (or unknown to clear)`, and
|
|
67
|
+
`unknown` clears the override back to the connector's declaration. Labels
|
|
68
|
+
already stored are ignored for pacing, never rejected on read. `bullswarm
|
|
69
|
+
pools` names the window in the meter column — `cmd-fixture cost=5
|
|
70
|
+
lanes=chore monthly used 79.4% elapsed 75.3% [cache] surplus=-4.1
|
|
71
|
+
inflight=0 5h=25.2% ready` — and `pools --json` entries carry
|
|
72
|
+
`pacingWindow`. `strategy refresh`/`show` print the window on each
|
|
73
|
+
subscription line (`command-code: GOAT · ... · monthly 79.4% used · surplus
|
|
74
|
+
-4.1`) and carry `pacingWindow` next to the free-text `quotaWindow` label in
|
|
75
|
+
`--json`; `strategy inventory --json` carries it per provider.
|
|
76
|
+
- Tests: 738 -> 750, 0 failures. The new behaviour is covered in
|
|
77
|
+
`tests/meters.test.js` (the live command-code snapshot as a fixture, the
|
|
78
|
+
helper's precedence, `buildPools` pacing), `tests/spend.test.js` (monthly
|
|
79
|
+
bootstrap window start, `spend.pacing`/`projectedPacingPct`),
|
|
80
|
+
`tests/route.test.js` (the penalty on the pacing window; weekly-only pools
|
|
81
|
+
unchanged), `tests/strategy-cli.test.js` (`--quota-window` validation) and
|
|
82
|
+
`tests/assignments.test.js` (the `pools` meter column and `--json`
|
|
83
|
+
`pacingWindow`).
|
|
84
|
+
|
|
3
85
|
## 0.28.0 — context diet
|
|
4
86
|
|
|
5
87
|
- `workflow runs result <id> --summary` prints a compact status-loop
|
|
@@ -77,9 +159,10 @@
|
|
|
77
159
|
39,288; compact `JSON.stringify` of the parsed envelope is 57,141.
|
|
78
160
|
`summarizeV2Result` of that fixture is 3,786 bytes —
|
|
79
161
|
`tests/workflow-result-summary.test.js` prints `result-summary size:
|
|
80
|
-
full=57141 summary=3786
|
|
81
|
-
inputs as 60,790 bytes; `wc -c` of the
|
|
82
|
-
`.diet-inputs/` sums to 46,022
|
|
162
|
+
full=57141 summary=3786` (numbers re-measured in 0.28.1). The 0.28.0
|
|
163
|
+
goal recorded the integrator's inputs as 60,790 bytes; `wc -c` of the
|
|
164
|
+
seven dependency out-files under `.diet-inputs/` sums to 46,022
|
|
165
|
+
(out-surface 18,659, out-routing-cleanup
|
|
83
166
|
10,202, out-state-bugs 7,052, out-docs 6,831, out-dead-kernel 1,377,
|
|
84
167
|
out-verify-gate 974, out-dead-code 927) and the integrator task file is
|
|
85
168
|
14,768 (`wc -c .diet-inputs/task-integrate-attempt-1.md`), which
|
package/README.md
CHANGED
|
@@ -51,7 +51,12 @@ detaches safely.
|
|
|
51
51
|
passing verification.
|
|
52
52
|
2. **Pace by meter.** The scheduling resource is the subscription window:
|
|
53
53
|
elapsed% minus used%, most-behind pool wins. Pace may only promote a
|
|
54
|
-
*cheaper* pool. Lanes are work-nature, never hard-coded to pools.
|
|
54
|
+
*cheaper* pool. Lanes are work-nature, never hard-coded to pools. Which
|
|
55
|
+
window paces one pool is the subscription window that pool's connector
|
|
56
|
+
declares (`quotaWindow`: weekly for claude-code, codex and grok; monthly
|
|
57
|
+
for command-code and the kaihk pools), overridable per pool with
|
|
58
|
+
`bullswarm strategy set-subscription <pool> --quota-window <weekly|monthly>`
|
|
59
|
+
— `bullswarm pools` names it in the meter column. The
|
|
55
60
|
5-hour window never paces — it gates: a pool at or above 75% of it is
|
|
56
61
|
chosen only when no eligible pool below that line exists, and one at or
|
|
57
62
|
above 90% is not dispatched at all.
|
|
@@ -656,6 +661,10 @@ These are UTF-8 byte counts, never tokens.
|
|
|
656
661
|
requirement as `{ id, status, mandatory, evidenceCount, why }`, each action as
|
|
657
662
|
`{ id, kind, lane, effort, status, pool, model, reasoning, wallSec, outFile,
|
|
658
663
|
bytes }`, `concerns: { count, first }`, `usage`, and `next: { full, runDir, outputs }` — every output name is a basename inside `next.runDir`.
|
|
664
|
+
`--summary` is single-line JSON (`JSON.stringify`), so the bytes on the wire
|
|
665
|
+
match the 4,096-byte fitter budget. As printed by the CLI on
|
|
666
|
+
`tests/fixtures/real-result-ze5xz2.json`, the compact summary is 3,786 bytes
|
|
667
|
+
and the pretty full envelope (`--json` alone) is 60,709 bytes.
|
|
659
668
|
The full `bullswarm.workflow.result.v2` envelope is unchanged and remains the
|
|
660
669
|
default. Read it (`--json` alone) on a failed or partial run, or before judging
|
|
661
670
|
evidence. A terminal `workflow watch` prints the same compact command as
|
package/package.json
CHANGED
|
@@ -249,8 +249,17 @@ bullswarm workflow runs show <id> --json # routing reason + candidates
|
|
|
249
249
|
- `bullswarm pools` carries `inflight=<n>` next to each pool's `5h=<n>%`
|
|
250
250
|
reading; `--json` adds the full `inflight` block (`count`, elapsed
|
|
251
251
|
`minutes`, `remainingMinutes`, `unknownExpected`, `records[]`) and each
|
|
252
|
-
pool's `spend.fiveHour` / `spend.weekly`
|
|
253
|
-
`projectedFiveHourPct` / `projectedWeeklyPct
|
|
252
|
+
pool's `spend.fiveHour` / `spend.weekly` / `spend.monthly` / `spend.pacing`
|
|
253
|
+
rates with `projectedFiveHourPct` / `projectedWeeklyPct` /
|
|
254
|
+
`projectedMonthlyPct` / `projectedPacingPct`.
|
|
255
|
+
- The meter column names the window the pool is paced by, e.g.
|
|
256
|
+
`command-code cost=1 lanes=analyze/build/chore monthly used 79.4% elapsed
|
|
257
|
+
75.3% [cache] surplus=-4.1 ...`. That window is the connector's declared
|
|
258
|
+
`quotaWindow` (monthly for command-code and the kaihk pools, weekly for
|
|
259
|
+
claude-code, codex and grok), overridable with `bullswarm strategy
|
|
260
|
+
set-subscription <pool> --quota-window <weekly|monthly>`; `pools --json`
|
|
261
|
+
carries it as `pacingWindow`, and `used%`/`elapsed%`/`surplus` are that
|
|
262
|
+
window's. The 5h reading still only gates.
|
|
254
263
|
- `bullswarm strategy rungs --json` answers "what would this pool actually run
|
|
255
264
|
on this tier, and what did it cost last time" in one row per pool and effort
|
|
256
265
|
tier: the effective model and its source, the effective reasoning level and
|
package/src/cli.js
CHANGED
|
@@ -96,9 +96,14 @@ async function cmdPools(opts) {
|
|
|
96
96
|
}
|
|
97
97
|
for (const p of pools) {
|
|
98
98
|
const src = p.meterSource;
|
|
99
|
+
// Name the window the numbers came from: `used`/`elapsed` mean different
|
|
100
|
+
// things for a weekly-paced and a monthly-paced pool, and the surplus
|
|
101
|
+
// routing compares is this window's. Only a real window reading is
|
|
102
|
+
// labeled — a declared meter has a number but no window.
|
|
103
|
+
const window = p.pacingWindow && p.elapsedPct != null ? `${p.pacingWindow} ` : '';
|
|
99
104
|
const meter = src === 'none'
|
|
100
105
|
? 'unmetered'
|
|
101
|
-
:
|
|
106
|
+
: `${window}used ${p.usedPct ?? '?'}% elapsed ${p.elapsedPct ?? '?'}% [${src}]`;
|
|
102
107
|
const burst = p.burstGate ? ' BURST-GATED' : '';
|
|
103
108
|
// 5h is a gate, never a pace (doctrine M3): show the reading and whether
|
|
104
109
|
// routing now deprioritizes this pool for it. When in-flight work makes
|
package/src/help.js
CHANGED
|
@@ -328,7 +328,7 @@ const poolsText = rich({
|
|
|
328
328
|
args: [],
|
|
329
329
|
options: [
|
|
330
330
|
{ flag: '--force', desc: 'bypass the meter cache and re-read live usage for every pool', default: 'off (cached meter readings reused within their TTL)' },
|
|
331
|
-
{ flag: '--json', desc: 'machine-readable pool array, each entry carrying inflight {count, minutes, remainingMinutes, unknownExpected, records[]}, spend {fiveHour, weekly} rates with their source and sample count, and projectedFiveHourPct / projectedWeeklyPct', default: 'human-readable aligned table' },
|
|
331
|
+
{ flag: '--json', desc: 'machine-readable pool array, each entry carrying inflight {count, minutes, remainingMinutes, unknownExpected, records[]}, spend {fiveHour, weekly, monthly, pacing} rates with their source and sample count, pacingWindow, and projectedFiveHourPct / projectedWeeklyPct / projectedMonthlyPct / projectedPacingPct', default: 'human-readable aligned table' },
|
|
332
332
|
],
|
|
333
333
|
safety: [
|
|
334
334
|
'calls each connector\'s live usage meter (network request per metered pool) to compute used/elapsed percentages',
|
|
@@ -660,15 +660,16 @@ const strategyIncludeModelText = rich({
|
|
|
660
660
|
});
|
|
661
661
|
|
|
662
662
|
const strategySetSubscriptionText = rich({
|
|
663
|
-
usage: 'bullswarm strategy set-subscription <pool> [--plan <name>] [--monthly-usd <n|unknown>] [--included-usd <n|unknown>] [--quota-window <
|
|
663
|
+
usage: 'bullswarm strategy set-subscription <pool> [--plan <name>] [--monthly-usd <n|unknown>] [--included-usd <n|unknown>] [--quota-window <weekly|monthly|unknown>]',
|
|
664
664
|
purpose: "Record known subscription pricing for a pool so refresh's value-multiple math "
|
|
665
|
-
+ '(included value vs. monthly cost) is accurate
|
|
665
|
+
+ '(included value vs. monthly cost) is accurate, and choose the quota window '
|
|
666
|
+
+ 'routing paces this pool by.',
|
|
666
667
|
args: [{ name: '<pool>', desc: 'connector/pool name to record economics for' }],
|
|
667
668
|
options: [
|
|
668
669
|
{ flag: '--plan <name>', desc: 'plan label to record', default: 'unchanged' },
|
|
669
670
|
{ flag: '--monthly-usd <n|unknown>', desc: 'monthly subscription price', default: 'unchanged' },
|
|
670
671
|
{ flag: '--included-usd <n|unknown>', desc: 'estimated included usage value', default: 'unchanged' },
|
|
671
|
-
{ flag: '--quota-window <
|
|
672
|
+
{ flag: '--quota-window <weekly|monthly>', desc: 'the subscription window that PACES routing for this pool (used% vs elapsed% of it); unknown clears it back to the connector default', default: 'unchanged' },
|
|
672
673
|
],
|
|
673
674
|
safety: ['writes state.strategy.subscriptions[pool] and invalidates the cached report'],
|
|
674
675
|
examples: [{ cmd: 'bullswarm strategy set-subscription claude --plan max --monthly-usd 200 --included-usd 1000' }],
|
package/src/lib/config.js
CHANGED
|
@@ -8,6 +8,13 @@
|
|
|
8
8
|
// Pace source (doctrine M2): the pacing object carries elapsed% computed
|
|
9
9
|
// from the provider's resets_at. Declared meters fall back to the local
|
|
10
10
|
// elapsed estimate and are visibly labeled.
|
|
11
|
+
//
|
|
12
|
+
// Pacing window (doctrine M3): WHICH window paces a pool is the pool's own
|
|
13
|
+
// subscription window — `state.strategy.subscriptions[pool].quotaWindow`,
|
|
14
|
+
// else `connector.subscription.quotaWindow` — resolved here, where both the
|
|
15
|
+
// state and the connector are in hand, so a cache, stale or live reading is
|
|
16
|
+
// paced identically. `p.pacingWindow` names the window the numbers on the
|
|
17
|
+
// pool view actually came from.
|
|
11
18
|
|
|
12
19
|
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
|
13
20
|
import { join } from 'node:path';
|
|
@@ -15,7 +22,7 @@ import { loadState } from './state.js';
|
|
|
15
22
|
import { paceScore, isQuarantined } from './route.js';
|
|
16
23
|
// One strict numeric coercion for the whole codebase (src/lib/num.js).
|
|
17
24
|
import { finiteOrNull } from './num.js';
|
|
18
|
-
import { FIVE_HOUR_NEAR_LIMIT_PCT } from '../meters/framework.js';
|
|
25
|
+
import { FIVE_HOUR_NEAR_LIMIT_PCT, pacingWindowFor, pickPacingWindow } from '../meters/framework.js';
|
|
19
26
|
import { expandClaudeAccountConnectors } from './claude-accounts.js';
|
|
20
27
|
import { expandOpenCodeKaihkConnectors } from './opencode-kaihk.js';
|
|
21
28
|
|
|
@@ -79,6 +86,13 @@ export function buildPools(bullswarmDir, now = Date.now(), readings = {}) {
|
|
|
79
86
|
usedPct: null,
|
|
80
87
|
elapsedPct: null,
|
|
81
88
|
pace: null,
|
|
89
|
+
// The subscription window that paces this pool, before any reading:
|
|
90
|
+
// the operator's setting, else the connector's declaration, else null
|
|
91
|
+
// (default order). Replaced below by the window a reading really used.
|
|
92
|
+
pacingWindow: pacingWindowFor({
|
|
93
|
+
connector: conn,
|
|
94
|
+
subscription: state.strategy?.subscriptions?.[name] ?? null,
|
|
95
|
+
}),
|
|
82
96
|
burstGate: false,
|
|
83
97
|
// 5h window (doctrine M3): gates routing, never paces it.
|
|
84
98
|
fiveHourUsedPct: null,
|
|
@@ -112,13 +126,15 @@ export function buildPools(bullswarmDir, now = Date.now(), readings = {}) {
|
|
|
112
126
|
p.fiveHourResetsAt = fiveHour.resetsAt;
|
|
113
127
|
p.nearFiveHourLimit = fiveHour.nearLimit;
|
|
114
128
|
}
|
|
115
|
-
|
|
129
|
+
const paced = pacedReading(reading, p.pacingWindow);
|
|
130
|
+
if (paced) {
|
|
116
131
|
// Provider-truth path (M1/M2)
|
|
117
132
|
p.meterSource = reading.source; // live | cache | stale
|
|
118
|
-
p.usedPct =
|
|
119
|
-
p.elapsedPct =
|
|
120
|
-
p.pace =
|
|
121
|
-
p.paceResetsAt =
|
|
133
|
+
p.usedPct = paced.pacing.usedPct;
|
|
134
|
+
p.elapsedPct = paced.pacing.elapsedPct;
|
|
135
|
+
p.pace = paced.pacing.surplus; // surplus = elapsed − used
|
|
136
|
+
p.paceResetsAt = paced.pacing.resetsAt;
|
|
137
|
+
p.pacingWindow = paced.window;
|
|
122
138
|
p.burstGate = reading.burstGate === true;
|
|
123
139
|
p.meterSnapshot = reading.snapshot ?? null;
|
|
124
140
|
} else {
|
|
@@ -139,6 +155,28 @@ export function buildPools(bullswarmDir, now = Date.now(), readings = {}) {
|
|
|
139
155
|
return { state, connectors, pools };
|
|
140
156
|
}
|
|
141
157
|
|
|
158
|
+
/**
|
|
159
|
+
* The window of a reading that paces this pool, and its name.
|
|
160
|
+
*
|
|
161
|
+
* `reading.windows` carries every window the provider reported, so the choice
|
|
162
|
+
* is made here rather than re-deriving it: 'monthly' takes monthly and falls
|
|
163
|
+
* back to weekly, anything else keeps the historical weekly-first order.
|
|
164
|
+
* A reading assembled without `windows` (a hand-built one, or an older code
|
|
165
|
+
* path) still carries `pacing`, which is used as-is — its window name is
|
|
166
|
+
* whatever the producer labeled, else the pool's declaration.
|
|
167
|
+
*
|
|
168
|
+
* @returns {{pacing: object, window: 'weekly'|'monthly'|null}|null}
|
|
169
|
+
*/
|
|
170
|
+
function pacedReading(reading, pacingWindow) {
|
|
171
|
+
if (!reading) return null;
|
|
172
|
+
if (reading.windows) {
|
|
173
|
+
const chosen = pickPacingWindow(reading.windows, pacingWindow);
|
|
174
|
+
if (chosen.pacing) return chosen;
|
|
175
|
+
}
|
|
176
|
+
if (!reading.pacing) return null;
|
|
177
|
+
return { pacing: reading.pacing, window: reading.pacingWindow ?? pacingWindow ?? null };
|
|
178
|
+
}
|
|
179
|
+
|
|
142
180
|
/**
|
|
143
181
|
* 5h window fields from a meter reading. Prefers the flat fields paceSnapshot
|
|
144
182
|
* produces and falls back to the raw snapshot, so a reading assembled by an
|
package/src/lib/forecast.js
CHANGED
|
@@ -74,8 +74,9 @@ export function attachForecast(pools, bullswarmDir, opts = {}) {
|
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
/**
|
|
77
|
-
* The flat surplus points charged per in-flight agent when no
|
|
78
|
-
*
|
|
77
|
+
* The flat surplus points charged per in-flight agent when no spend rate is
|
|
78
|
+
* known for the pool's pacing window, from core state when an operator
|
|
79
|
+
* configured one.
|
|
79
80
|
* `config.inflightPenaltyPct: 0` disables the tie-breaker entirely; anything
|
|
80
81
|
* unusable falls back to route.js's documented default.
|
|
81
82
|
*/
|
package/src/lib/route.js
CHANGED
|
@@ -45,8 +45,9 @@ export const LANES = ['analyze', 'build', 'chore'];
|
|
|
45
45
|
export const INCUMBENCY_MARGIN = 10; // surplus points a challenger must beat
|
|
46
46
|
|
|
47
47
|
/**
|
|
48
|
-
* Surplus points charged per in-flight agent when no
|
|
49
|
-
*
|
|
48
|
+
* Surplus points charged per in-flight agent when no spend rate is known for
|
|
49
|
+
* the pool's pacing window (or, failing that, its weekly one). It is a
|
|
50
|
+
* tie-breaker, not a measurement: three points is
|
|
50
51
|
* under a third of INCUMBENCY_MARGIN, so it separates pools of similar pace
|
|
51
52
|
* without ever overturning a real quota difference. Callers override it with
|
|
52
53
|
* opts.inflightPenaltyPct.
|
|
@@ -140,30 +141,37 @@ export function fiveHourForecast(pool, candidateMinutes = null) {
|
|
|
140
141
|
}
|
|
141
142
|
|
|
142
143
|
/**
|
|
143
|
-
*
|
|
144
|
-
* routed to it (R8 rule c). The result is subtracted from the pace
|
|
145
|
-
* that, between pools of similar pace, the quieter one wins.
|
|
144
|
+
* Pacing-window cost of the work a pool is already carrying plus the work
|
|
145
|
+
* being routed to it (R8 rule c). The result is subtracted from the pace
|
|
146
|
+
* surplus so that, between pools of similar pace, the quieter one wins.
|
|
147
|
+
*
|
|
148
|
+
* The rate is read from `spend.pacing` — the rate for the window this pool is
|
|
149
|
+
* actually paced by — and falls back to `spend.weekly` when no pacing rate is
|
|
150
|
+
* known (a pool paced monthly with no monthly rate, or a producer that
|
|
151
|
+
* attached only the weekly one). Charging a weekly rate against a monthly
|
|
152
|
+
* surplus would compare points from two different windows.
|
|
146
153
|
*
|
|
147
154
|
* Two bases, and the larger one is charged:
|
|
148
|
-
* 1. a known
|
|
155
|
+
* 1. a known rate: rate × each in-flight record's remainingMinutes,
|
|
149
156
|
* plus rate × candidateMinutes — real projected percentage points (an
|
|
150
157
|
* in-flight agent whose remaining minutes nobody recorded is charged
|
|
151
158
|
* inflightPenaltyPct instead);
|
|
152
159
|
* 2. the floor: inflightPenaltyPct per in-flight agent — a documented flat
|
|
153
160
|
* default, labeled `penalty` so no reader mistakes it for a measurement.
|
|
154
|
-
* The floor exists because at real
|
|
155
|
-
* worker-minute) a six-minute agent projects to under a point,
|
|
156
|
-
* spread a burst across a pace gap of a few points; the measured
|
|
157
|
-
* only ever raises the charge above the floor.
|
|
161
|
+
* The floor exists because at real subscription-window rates (about 0.05
|
|
162
|
+
* points per worker-minute) a six-minute agent projects to under a point,
|
|
163
|
+
* which cannot spread a burst across a pace gap of a few points; the measured
|
|
164
|
+
* projection only ever raises the charge above the floor.
|
|
158
165
|
*
|
|
159
166
|
* Only `inflight.records[].remainingMinutes` is read: `inflight.minutes` is
|
|
160
167
|
* elapsed worker-minutes (src/lib/assignments.js attachInflight), which says
|
|
161
168
|
* nothing about the quota still to be spent.
|
|
162
169
|
*
|
|
163
170
|
* estimateSource: `none` (nothing to charge), `penalty` (the flat floor set the
|
|
164
|
-
* charge), or the
|
|
165
|
-
* `bootstrap`) when the measured
|
|
166
|
-
* rate was used but the producer
|
|
171
|
+
* charge), or the source label of the rate that was used (`history` /
|
|
172
|
+
* `bootstrap`, from `spend.pacing` or `spend.weekly`) when the measured
|
|
173
|
+
* projection exceeded the floor; null when a rate was used but the producer
|
|
174
|
+
* labeled no provenance for it.
|
|
167
175
|
*
|
|
168
176
|
* @returns {{count: number, penalty: number, ratePerMinute: number|null,
|
|
169
177
|
* estimateSource: string|null}}
|
|
@@ -174,13 +182,16 @@ export function inflightLoad(pool, opts = {}) {
|
|
|
174
182
|
inflightPenaltyPct = DEFAULT_INFLIGHT_PENALTY_PCT,
|
|
175
183
|
} = opts;
|
|
176
184
|
const count = Math.max(0, num(pool?.inflight?.count) ?? 0);
|
|
177
|
-
|
|
185
|
+
// The pacing window's rate, or the weekly one when that window has no
|
|
186
|
+
// measured rate — the surplus and the penalty stay on the same window.
|
|
187
|
+
const paced = num(pool?.spend?.pacing?.ratePerMinute) != null
|
|
188
|
+
? pool.spend.pacing
|
|
189
|
+
: pool?.spend?.weekly ?? null;
|
|
190
|
+
const rate = num(paced?.ratePerMinute);
|
|
178
191
|
const minutes = num(candidateMinutes);
|
|
179
192
|
const penaltyPct = num(inflightPenaltyPct) ?? DEFAULT_INFLIGHT_PENALTY_PCT;
|
|
180
193
|
const sourceLabel =
|
|
181
|
-
typeof
|
|
182
|
-
? pool.spend.weekly.source
|
|
183
|
-
: null;
|
|
194
|
+
typeof paced?.source === 'string' && paced.source ? paced.source : null;
|
|
184
195
|
|
|
185
196
|
if (rate == null) {
|
|
186
197
|
return {
|
|
@@ -238,8 +249,10 @@ export function isExhausted(pool) {
|
|
|
238
249
|
* attached by the caller when it tracks them:
|
|
239
250
|
* inflight {count, minutes, records:[{remainingMinutes}]},
|
|
240
251
|
* spend {fiveHour:{ratePerMinute, source},
|
|
241
|
-
* weekly:{
|
|
242
|
-
*
|
|
252
|
+
* weekly:{...}, monthly:{...},
|
|
253
|
+
* pacing:{window, ratePerMinute, source}},
|
|
254
|
+
* pacingWindow, projectedFiveHourPct,
|
|
255
|
+
* projectedWeeklyPct, projectedPacingPct.
|
|
243
256
|
* @param {object} [opts] { callerEligible=true, callerName='claude', now,
|
|
244
257
|
* requiredCapabilities, preferredPool, effortTier,
|
|
245
258
|
* callerSession, candidateMinutes=null (expected minutes
|
|
@@ -333,6 +346,10 @@ export function pickPool(lane, pools, opts = {}) {
|
|
|
333
346
|
projectedFiveHourPct: e.forecast.projected,
|
|
334
347
|
forecastFiveHourPct: e.forecast.forecast == null ? null : tenth(e.forecast.forecast),
|
|
335
348
|
projectedWeeklyPct: num(e.pool.projectedWeeklyPct),
|
|
349
|
+
// The window this pool is paced by, and the projection in it. Equal to
|
|
350
|
+
// the weekly pair for every pool that declares no monthly quota window.
|
|
351
|
+
pacingWindow: e.pool.pacingWindow ?? null,
|
|
352
|
+
projectedPacingPct: num(e.pool.projectedPacingPct),
|
|
336
353
|
ratePerMinute: e.forecast.ratePerMinute,
|
|
337
354
|
estimateSource: e.load.estimateSource,
|
|
338
355
|
nearFiveHourLimit: e.tier === 1,
|
package/src/lib/spend.js
CHANGED
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
|
|
16
16
|
import {
|
|
17
17
|
WINDOW_KEYS,
|
|
18
|
+
monthlyWindowMs,
|
|
19
|
+
normalizePacingWindow,
|
|
18
20
|
projectedUtilization,
|
|
19
21
|
} from '../meters/framework.js';
|
|
20
22
|
import { readMeterHistory } from '../meters/registry.js';
|
|
@@ -311,9 +313,13 @@ function rateForWindow(meta, { history, snapshot, workerMinutesBetween, nowMs })
|
|
|
311
313
|
// 2. Bootstrap: the whole window so far — current utilization over the
|
|
312
314
|
// worker-minutes dispatched since the window opened (M2: the start comes
|
|
313
315
|
// from the provider's resets_at, never from a locally assumed start).
|
|
316
|
+
// A window with no constant length (monthly) takes its length from that
|
|
317
|
+
// same resets_at — the calendar month ending on it — so the start is
|
|
318
|
+
// still the provider's, never a 30-day assumption.
|
|
314
319
|
const resetsAtMs = num(Date.parse(latest?.resetsAt ?? ''));
|
|
315
|
-
|
|
316
|
-
|
|
320
|
+
const windowMs = meta.windowMs ?? (resetsAtMs != null ? monthlyWindowMs(resetsAtMs) : null);
|
|
321
|
+
if (latest && resetsAtMs != null && num(windowMs) != null && latest.usedPct > 0 && minutesBetween) {
|
|
322
|
+
const minutes = num(minutesBetween(resetsAtMs - windowMs, nowMs));
|
|
317
323
|
if (minutes != null && minutes >= MIN_RATE_MINUTES) {
|
|
318
324
|
return {
|
|
319
325
|
ratePerMinute: round(latest.usedPct / minutes, 6),
|
|
@@ -336,7 +342,7 @@ function rateForWindow(meta, { history, snapshot, workerMinutesBetween, nowMs })
|
|
|
336
342
|
* @param {{history?: Array<object>,
|
|
337
343
|
* workerMinutesBetween?: (fromMs: number, toMs: number) => number,
|
|
338
344
|
* nowMs?: number, snapshot?: object|null}} [opts]
|
|
339
|
-
* @returns {{fiveHour: object, weekly: object}} each
|
|
345
|
+
* @returns {{fiveHour: object, weekly: object, monthly: object}} each
|
|
340
346
|
* `{ratePerMinute, source: 'history'|'bootstrap'|null, samples, windowUsedPct}`
|
|
341
347
|
*/
|
|
342
348
|
export function spendRateFor(pool, opts = {}) {
|
|
@@ -374,10 +380,10 @@ function historyResolver({ history = null, readHistory = null, historyFor = null
|
|
|
374
380
|
/**
|
|
375
381
|
* Current utilization for one window of a pool view.
|
|
376
382
|
*
|
|
377
|
-
* The weekly
|
|
378
|
-
* has ever written `pool.weeklyUsedPct` — buildPools writes
|
|
379
|
-
* pacing window) and `fiveHourUsedPct` — so reading it first
|
|
380
|
-
* fall-through look conditional when it never was.
|
|
383
|
+
* The weekly and monthly branches read the rate's own window utilization
|
|
384
|
+
* only: no producer has ever written `pool.weeklyUsedPct` — buildPools writes
|
|
385
|
+
* `usedPct` (the pacing window) and `fiveHourUsedPct` — so reading it first
|
|
386
|
+
* only made the fall-through look conditional when it never was.
|
|
381
387
|
*/
|
|
382
388
|
function currentUsedPct(pool, key, rate) {
|
|
383
389
|
if (key === 'fiveHour') return num(pool?.fiveHourUsedPct) ?? rate.windowUsedPct ?? null;
|
|
@@ -387,8 +393,13 @@ function currentUsedPct(pool, key, rate) {
|
|
|
387
393
|
/**
|
|
388
394
|
* Attach the spend model to pool views in place:
|
|
389
395
|
*
|
|
390
|
-
* pool.spend.fiveHour /
|
|
391
|
-
* pool.
|
|
396
|
+
* pool.spend.fiveHour / .weekly / .monthly = {ratePerMinute, source, samples}
|
|
397
|
+
* pool.spend.pacing = {window, ratePerMinute, source, samples} — the same
|
|
398
|
+
* numbers for the window that PACES this pool (`pool.pacingWindow`,
|
|
399
|
+
* default weekly), so the surplus routing compares and the load it
|
|
400
|
+
* charges are measured in the same window.
|
|
401
|
+
* pool.projectedFiveHourPct / pool.projectedWeeklyPct /
|
|
402
|
+
* pool.projectedMonthlyPct / pool.projectedPacingPct
|
|
392
403
|
* = current utilization + rate × the remaining minutes of the pool's
|
|
393
404
|
* in-flight work (this candidate is NOT included — src/lib/route.js
|
|
394
405
|
* adds the assignment being routed on top).
|
|
@@ -428,20 +439,29 @@ export function attachSpend(pools, opts = {}) {
|
|
|
428
439
|
remainingMinutes += remainingMinutesOf(record, nowMs) ?? 0;
|
|
429
440
|
}
|
|
430
441
|
|
|
442
|
+
// The window this pool is paced by decides which rate routing charges.
|
|
443
|
+
// Default weekly: that is what every pool was paced by before 0.28.1, so
|
|
444
|
+
// a pool that declares nothing keeps exactly its old numbers.
|
|
445
|
+
const pacingWindow = normalizePacingWindow(pool?.pacingWindow) ?? 'weekly';
|
|
446
|
+
const rateOf = (key) => ({
|
|
447
|
+
ratePerMinute: rates[key].ratePerMinute,
|
|
448
|
+
source: rates[key].source,
|
|
449
|
+
samples: rates[key].samples,
|
|
450
|
+
});
|
|
451
|
+
|
|
431
452
|
pool.spend = {
|
|
432
|
-
fiveHour:
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
},
|
|
437
|
-
weekly: {
|
|
438
|
-
ratePerMinute: rates.weekly.ratePerMinute,
|
|
439
|
-
source: rates.weekly.source,
|
|
440
|
-
samples: rates.weekly.samples,
|
|
441
|
-
},
|
|
453
|
+
fiveHour: rateOf('fiveHour'),
|
|
454
|
+
weekly: rateOf('weekly'),
|
|
455
|
+
monthly: rateOf('monthly'),
|
|
456
|
+
pacing: { window: pacingWindow, ...rateOf(pacingWindow) },
|
|
442
457
|
};
|
|
443
458
|
|
|
444
|
-
for (const [key, field] of [
|
|
459
|
+
for (const [key, field] of [
|
|
460
|
+
['fiveHour', 'projectedFiveHourPct'],
|
|
461
|
+
['weekly', 'projectedWeeklyPct'],
|
|
462
|
+
['monthly', 'projectedMonthlyPct'],
|
|
463
|
+
[pacingWindow, 'projectedPacingPct'],
|
|
464
|
+
]) {
|
|
445
465
|
const projection = projectedUtilization({
|
|
446
466
|
usedPct: currentUsedPct(pool, key, rates[key]),
|
|
447
467
|
ratePerMinute: rates[key].ratePerMinute,
|
package/src/lib/strategy.js
CHANGED
|
@@ -7,6 +7,7 @@ import { modelProfile } from './usage.js';
|
|
|
7
7
|
import { openRouterMetadata } from './openrouter-models.js';
|
|
8
8
|
import { isReasoningLevel, REASONING_LEVELS, resolveReasoningLevel } from './reasoning.js';
|
|
9
9
|
import { attemptWindow } from './spend.js';
|
|
10
|
+
import { pacingWindowFor } from '../meters/framework.js';
|
|
10
11
|
// The canonical lane/effort tables. Imported, never restated: see
|
|
11
12
|
// TIER_CONTEXTS below for the tier -> lane derivation they feed.
|
|
12
13
|
import { DEFAULT_EFFORT_BY_LANE, KIND_DEFAULTS } from '../workflow/action-validator.js';
|
|
@@ -550,7 +551,12 @@ function subscriptionView(pool, state) {
|
|
|
550
551
|
includedValueUsd,
|
|
551
552
|
valueMultiple: monthlyPriceUsd > 0 && includedValueUsd != null
|
|
552
553
|
? Math.round((includedValueUsd / monthlyPriceUsd) * 100) / 100 : null,
|
|
554
|
+
// `quotaWindow` is the label as declared (it may name several windows,
|
|
555
|
+
// e.g. "weekly+monthly+5h"); `pacingWindow` is the one window routing
|
|
556
|
+
// actually paces this pool by — see src/meters/framework.js.
|
|
553
557
|
quotaWindow: declared.quotaWindow ?? pool.connector?.meter?.window ?? null,
|
|
558
|
+
pacingWindow: pool.pacingWindow
|
|
559
|
+
?? pacingWindowFor({ connector, subscription: state.strategy?.subscriptions?.[pool.name] }),
|
|
554
560
|
quota: monthlyQuota,
|
|
555
561
|
meterSource: pool.meterSource ?? 'none',
|
|
556
562
|
usedPct: pool.usedPct ?? null,
|
package/src/meters/framework.js
CHANGED
|
@@ -9,7 +9,11 @@
|
|
|
9
9
|
// M3. Weekly/monthly windows pace routing; 5h windows are gates only
|
|
10
10
|
// (they never pace): >= BURST_BLOCK_PCT blocks dispatch outright and
|
|
11
11
|
// >= FIVE_HOUR_NEAR_LIMIT_PCT deprioritizes the pool while any pool
|
|
12
|
-
// with 5h headroom is eligible.
|
|
12
|
+
// with 5h headroom is eligible. WHICH of weekly/monthly paces one pool
|
|
13
|
+
// is the pool's own subscription window (`quotaWindow`), not a global
|
|
14
|
+
// preference: command-code buys a monthly credit allocation and only
|
|
15
|
+
// rate-limits weekly, so pacing it by its weekly window sends work to a
|
|
16
|
+
// pool whose real budget is already overspent.
|
|
13
17
|
// M4. Readers fail closed: an unreadable response is an error, not a
|
|
14
18
|
// zero. A stale cached reading is shown with its age.
|
|
15
19
|
// M5. Auth tokens are read from each CLI's native store; refresh
|
|
@@ -41,21 +45,69 @@ export function windowPace({ usedPct, resetsAtMs, windowMs, nowMs = Date.now() }
|
|
|
41
45
|
};
|
|
42
46
|
}
|
|
43
47
|
|
|
48
|
+
/** The two windows that may pace a pool. 5h is never one of them (M3). */
|
|
49
|
+
export const PACING_WINDOWS = ['weekly', 'monthly'];
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A quota-window label as pacing understands it, or null.
|
|
53
|
+
*
|
|
54
|
+
* Labels are free text on disk — `strategy set-subscription --quota-window`
|
|
55
|
+
* has always written whatever it was handed, and connectors describe meters
|
|
56
|
+
* with strings like "weekly+monthly+5h". Anything that is not exactly one
|
|
57
|
+
* pacing window is null: unknown, so pacing keeps its default order rather
|
|
58
|
+
* than guessing which window an operator meant.
|
|
59
|
+
*/
|
|
60
|
+
export function normalizePacingWindow(value) {
|
|
61
|
+
if (typeof value !== 'string') return null;
|
|
62
|
+
const name = value.trim().toLowerCase();
|
|
63
|
+
return PACING_WINDOWS.includes(name) ? name : null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The window a pool's quota actually lives in: the operator's stored
|
|
68
|
+
* subscription first (`state.strategy.subscriptions[pool].quotaWindow`), then
|
|
69
|
+
* the connector's declaration (`connector.subscription.quotaWindow`).
|
|
70
|
+
*
|
|
71
|
+
* Precedence is by VALUE, not by validity: a stored label the operator set
|
|
72
|
+
* wins over the connector's even when it is unrecognised, and an unrecognised
|
|
73
|
+
* label resolves to null (today's default order) rather than silently falling
|
|
74
|
+
* through to a window the operator did not choose. `strategy set-subscription`
|
|
75
|
+
* now rejects labels that are neither, so only pre-0.28.1 state can hold one.
|
|
76
|
+
*
|
|
77
|
+
* @param {{connector?: object|null, subscription?: object|null}} [pool]
|
|
78
|
+
* @returns {'weekly'|'monthly'|null}
|
|
79
|
+
*/
|
|
80
|
+
export function pacingWindowFor({ connector = null, subscription = null } = {}) {
|
|
81
|
+
const declared = subscription?.quotaWindow ?? connector?.subscription?.quotaWindow ?? null;
|
|
82
|
+
return normalizePacingWindow(declared);
|
|
83
|
+
}
|
|
84
|
+
|
|
44
85
|
/**
|
|
45
86
|
* Pace a snapshot per doctrine M3:
|
|
46
|
-
* - pacing window =
|
|
87
|
+
* - pacing window = the pool's own subscription window when it declares one
|
|
88
|
+
* ('monthly' → monthly ?? weekly, 'weekly' → weekly ?? monthly), else the
|
|
89
|
+
* default order weekly ?? monthly ?? none. Never 5h.
|
|
47
90
|
* - burst gate = 5h utilization >= BURST_BLOCK_PCT blocks dispatch
|
|
48
91
|
* - near limit = 5h utilization >= FIVE_HOUR_NEAR_LIMIT_PCT: still
|
|
49
92
|
* dispatchable, but routing prefers any pool with 5h headroom
|
|
93
|
+
*
|
|
94
|
+
* `pacingWindow` on the result names the window the numbers actually came
|
|
95
|
+
* from ('weekly' | 'monthly' | null) — which is the requested one only when
|
|
96
|
+
* the provider reported it.
|
|
97
|
+
*
|
|
98
|
+
* @param {object|null} snapshot
|
|
99
|
+
* @param {number} [nowMs]
|
|
100
|
+
* @param {{pacingWindow?: string|null}} [opts]
|
|
50
101
|
*/
|
|
51
102
|
export const BURST_BLOCK_PCT = 90;
|
|
52
103
|
/** 5h utilization at/above which routing treats a pool as near its limit. */
|
|
53
104
|
export const FIVE_HOUR_NEAR_LIMIT_PCT = 75;
|
|
54
105
|
|
|
55
|
-
export function paceSnapshot(snapshot, nowMs = Date.now()) {
|
|
106
|
+
export function paceSnapshot(snapshot, nowMs = Date.now(), opts = {}) {
|
|
56
107
|
if (!snapshot) {
|
|
57
108
|
return {
|
|
58
109
|
pacing: null,
|
|
110
|
+
pacingWindow: null,
|
|
59
111
|
burstGate: false,
|
|
60
112
|
windows: {},
|
|
61
113
|
fiveHourUsedPct: null,
|
|
@@ -81,7 +133,7 @@ export function paceSnapshot(snapshot, nowMs = Date.now()) {
|
|
|
81
133
|
});
|
|
82
134
|
}
|
|
83
135
|
|
|
84
|
-
const
|
|
136
|
+
const chosen = pickPacingWindow(windows, opts.pacingWindow);
|
|
85
137
|
const fiveHourUsed = snapshot.five_hour?.utilization;
|
|
86
138
|
const fiveHourUsedPct = Number.isFinite(fiveHourUsed) ? fiveHourUsed : null;
|
|
87
139
|
const burstGate = fiveHourUsedPct != null && fiveHourUsedPct >= BURST_BLOCK_PCT;
|
|
@@ -92,7 +144,8 @@ export function paceSnapshot(snapshot, nowMs = Date.now()) {
|
|
|
92
144
|
: NaN;
|
|
93
145
|
|
|
94
146
|
return {
|
|
95
|
-
pacing,
|
|
147
|
+
pacing: chosen.pacing,
|
|
148
|
+
pacingWindow: chosen.window,
|
|
96
149
|
burstGate,
|
|
97
150
|
windows,
|
|
98
151
|
fiveHourUsedPct,
|
|
@@ -104,15 +157,38 @@ export function paceSnapshot(snapshot, nowMs = Date.now()) {
|
|
|
104
157
|
};
|
|
105
158
|
}
|
|
106
159
|
|
|
160
|
+
/**
|
|
161
|
+
* The paced window out of a `windows` map, honouring the pool's declared
|
|
162
|
+
* window and falling back to the other one when the provider did not report
|
|
163
|
+
* the declared one (a reading with only a weekly window still paces).
|
|
164
|
+
*
|
|
165
|
+
* @param {{seven_day?: object|null, monthly?: object|null}} windows
|
|
166
|
+
* @param {string|null} [requested]
|
|
167
|
+
* @returns {{pacing: object|null, window: 'weekly'|'monthly'|null}}
|
|
168
|
+
*/
|
|
169
|
+
export function pickPacingWindow(windows = {}, requested = null) {
|
|
170
|
+
const order = normalizePacingWindow(requested) === 'monthly'
|
|
171
|
+
? [['monthly', 'monthly'], ['seven_day', 'weekly']]
|
|
172
|
+
: [['seven_day', 'weekly'], ['monthly', 'monthly']];
|
|
173
|
+
for (const [key, name] of order) {
|
|
174
|
+
const pacing = windows?.[key] ?? null;
|
|
175
|
+
if (pacing) return { pacing, window: name };
|
|
176
|
+
}
|
|
177
|
+
return { pacing: null, window: null };
|
|
178
|
+
}
|
|
179
|
+
|
|
107
180
|
/**
|
|
108
181
|
* Window names the spend model works in, mapped to where each one lives.
|
|
109
182
|
* - `snapshot`: the key a provider reading uses (`seven_day` for weekly)
|
|
110
183
|
* - `history`: the key a history line uses (`weekly`)
|
|
111
|
-
* - `windowMs`: the window length, for deriving its start from resets_at
|
|
184
|
+
* - `windowMs`: the window length, for deriving its start from resets_at
|
|
185
|
+
* (M2) — null for the monthly window, whose length is the calendar month
|
|
186
|
+
* ending at the provider's resets_at (monthlyWindowMs), not a constant.
|
|
112
187
|
*/
|
|
113
188
|
export const WINDOW_KEYS = {
|
|
114
189
|
fiveHour: { snapshot: 'five_hour', history: 'five_hour', windowMs: WINDOW_MS['5h'] },
|
|
115
190
|
weekly: { snapshot: 'seven_day', history: 'weekly', windowMs: WINDOW_MS.weekly },
|
|
191
|
+
monthly: { snapshot: 'monthly', history: 'monthly', windowMs: null },
|
|
116
192
|
};
|
|
117
193
|
|
|
118
194
|
/**
|
package/src/strategy-cli.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { loadState, updateState } from './lib/state.js';
|
|
2
2
|
import { loadConnectors, buildPools, buildPoolsLive } from './lib/config.js';
|
|
3
3
|
import { getAllMeterReadings } from './meters/registry.js';
|
|
4
|
+
import { PACING_WINDOWS } from './meters/framework.js';
|
|
4
5
|
import {
|
|
5
6
|
discoverAllModels, buildStrategy, normalizeExcludedModels, resolveDispatchModel,
|
|
6
7
|
selectedModelsForTier, setModelTierSelection, STRATEGY_TIERS,
|
|
@@ -60,6 +61,25 @@ function numberOrNull(value, label) {
|
|
|
60
61
|
return n;
|
|
61
62
|
}
|
|
62
63
|
|
|
64
|
+
/**
|
|
65
|
+
* `--quota-window` is no longer a free-text label: it selects the window that
|
|
66
|
+
* PACES this pool (src/meters/framework.js pacingWindowFor), so a value
|
|
67
|
+
* pacing cannot act on is refused instead of being stored and ignored.
|
|
68
|
+
* `unknown`/`null` clears it, exactly like the price flags, which is the way
|
|
69
|
+
* back for a pre-0.28.1 label that is neither window.
|
|
70
|
+
*/
|
|
71
|
+
function quotaWindowValue(value) {
|
|
72
|
+
if (value === undefined) return undefined;
|
|
73
|
+
if (value === 'unknown' || value === 'null') return null;
|
|
74
|
+
const name = typeof value === 'string' ? value.trim().toLowerCase() : '';
|
|
75
|
+
if (!PACING_WINDOWS.includes(name)) {
|
|
76
|
+
throw new Error(
|
|
77
|
+
`--quota-window must be ${PACING_WINDOWS.join(' or ')} (or unknown to clear)`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
return name;
|
|
81
|
+
}
|
|
82
|
+
|
|
63
83
|
function refreshHoursValue(value) {
|
|
64
84
|
const hours = Number(value ?? 24);
|
|
65
85
|
if (!Number.isFinite(hours) || hours <= 0) throw new Error('refresh-hours must be a positive number');
|
|
@@ -72,7 +92,10 @@ function render(report, reasoning = null) {
|
|
|
72
92
|
const value = sub.monthlyPriceUsd == null || sub.includedValueUsd == null
|
|
73
93
|
? 'value unknown'
|
|
74
94
|
: `$${sub.monthlyPriceUsd}/mo → ~$${sub.includedValueUsd} included (${sub.valueMultiple}×)`;
|
|
75
|
-
|
|
95
|
+
// Which window paces this pool is the difference between "behind" and
|
|
96
|
+
// "overspent" for the same reading, so the line names it.
|
|
97
|
+
const paced = sub.pacingWindow ? `${sub.pacingWindow} ` : '';
|
|
98
|
+
lines.push(` ${sub.pool}: ${sub.plan ?? 'plan unknown'} · ${value} · ${paced}${sub.usedPct ?? '?'}% used · surplus ${sub.surplus ?? '?'}`);
|
|
76
99
|
}
|
|
77
100
|
lines.push('', 'tier suggestions:');
|
|
78
101
|
for (const [tier, suggestion] of Object.entries(report.suggestions)) {
|
|
@@ -378,6 +401,9 @@ export function strategyInventory({ pools, state, report, evidence = null }) {
|
|
|
378
401
|
enabled: pool.enabled !== false,
|
|
379
402
|
usedPct: pool.usedPct ?? null,
|
|
380
403
|
surplus: pool.pace ?? null,
|
|
404
|
+
// The window `usedPct` and `surplus` are measured in, and that routing
|
|
405
|
+
// paces this pool by (weekly | monthly | null = weekly-first default).
|
|
406
|
+
pacingWindow: pool.pacingWindow ?? null,
|
|
381
407
|
// How many agents this pool is running right now, across every
|
|
382
408
|
// Bullswarm process — the same count `bullswarm pools` reports.
|
|
383
409
|
inflight: pool.inflight?.count ?? 0,
|
|
@@ -487,6 +513,9 @@ export async function loadStrategyInventory(bullswarmDir, {
|
|
|
487
513
|
pool.usedPct = live.usedPct;
|
|
488
514
|
pool.pace = live.surplus;
|
|
489
515
|
pool.meterSource = live.meterSource;
|
|
516
|
+
// The report's numbers came from a window; carry its name with them so
|
|
517
|
+
// the inventory view does not label them with a different one.
|
|
518
|
+
pool.pacingWindow = live.pacingWindow ?? pool.pacingWindow ?? null;
|
|
490
519
|
}
|
|
491
520
|
// The control center previews real routing, so it reads the same live
|
|
492
521
|
// in-flight ledger and spend rates every dispatch path does.
|
|
@@ -878,6 +907,7 @@ export async function cmdStrategy(args, {
|
|
|
878
907
|
// Flag validation first, so a bad number fails before any lock is taken.
|
|
879
908
|
const monthlyPriceUsd = numberOrNull(opts['monthly-usd'], 'monthly-usd');
|
|
880
909
|
const includedValueUsd = numberOrNull(opts['included-usd'], 'included-usd');
|
|
910
|
+
const quotaWindow = quotaWindowValue(opts['quota-window']);
|
|
881
911
|
const state = updateState(bullswarmDir, (fresh) => {
|
|
882
912
|
fresh.strategy ??= {};
|
|
883
913
|
fresh.strategy.subscriptions ??= {};
|
|
@@ -887,7 +917,7 @@ export async function cmdStrategy(args, {
|
|
|
887
917
|
...(opts.plan !== undefined ? { plan: opts.plan } : {}),
|
|
888
918
|
...(monthlyPriceUsd !== undefined ? { monthlyPriceUsd } : {}),
|
|
889
919
|
...(includedValueUsd !== undefined ? { includedValueUsd } : {}),
|
|
890
|
-
...(
|
|
920
|
+
...(quotaWindow !== undefined ? { quotaWindow } : {}),
|
|
891
921
|
};
|
|
892
922
|
delete fresh.strategy.lastReport;
|
|
893
923
|
});
|
|
@@ -973,7 +1003,7 @@ export async function cmdStrategy(args, {
|
|
|
973
1003
|
throw new Error(strategyUsage());
|
|
974
1004
|
} catch (err) {
|
|
975
1005
|
console.error(`✗ ${err.message}`);
|
|
976
|
-
const usage = /^(usage:|missing |assignment needs |--apply changes|(?:strategy )?(?:apply|auto off|configure|set-provider|set-model|reset-tier|set-reasoning|reset-reasoning) changes|--tiers? must be|--level must be|--reasoning must be|reasoning(?:\.|\s)|refresh-hours must be|.* must be a non-negative number|unknown phase|unknown command|unknown pool|unknown tier|unknown model)/i.test(err.message);
|
|
1006
|
+
const usage = /^(usage:|missing |assignment needs |--apply changes|(?:strategy )?(?:apply|auto off|configure|set-provider|set-model|reset-tier|set-reasoning|reset-reasoning) changes|--tiers? must be|--level must be|--reasoning must be|--quota-window must be|reasoning(?:\.|\s)|refresh-hours must be|.* must be a non-negative number|unknown phase|unknown command|unknown pool|unknown tier|unknown model)/i.test(err.message);
|
|
977
1007
|
return usage ? 2 : 1;
|
|
978
1008
|
}
|
|
979
1009
|
}
|
package/src/workflow/runs-cli.js
CHANGED
|
@@ -27,7 +27,11 @@ import { deserializeV2ResultEnvelope, summarizeV2Result } from './v2-outcome.js'
|
|
|
27
27
|
import { helpText, usageLine } from '../help.js';
|
|
28
28
|
import { flagName, unknownFlagExit } from '../lib/cli-flags.js';
|
|
29
29
|
|
|
30
|
-
function jsonOut(obj, opts) {
|
|
30
|
+
function jsonOut(obj, opts) {
|
|
31
|
+
if (!(opts.json || opts.summary)) return;
|
|
32
|
+
// Summary is budgeted against compact JSON.stringify; --json alone stays pretty.
|
|
33
|
+
console.log(opts.summary ? JSON.stringify(obj) : JSON.stringify(obj, null, 2));
|
|
34
|
+
}
|
|
31
35
|
function err(msg, code = 1) { console.error(msg); return code; }
|
|
32
36
|
|
|
33
37
|
// Every command that would drive a legacy run answers with the same sentence
|
|
@@ -225,7 +225,7 @@ export async function dispatchV2Action({
|
|
|
225
225
|
};
|
|
226
226
|
const coreDecisionLog = () => safeCoreState()?.decisionLog ?? [];
|
|
227
227
|
// Operator-configurable, read once: the flat surplus cost of an in-flight
|
|
228
|
-
// agent on a pool whose
|
|
228
|
+
// agent on a pool whose pacing-window spend rate nobody has measured yet.
|
|
229
229
|
const inflightPenaltyPct = inflightPenaltyFrom(safeCoreState());
|
|
230
230
|
// One expectation for the whole action: lane and effort do not change
|
|
231
231
|
// between attempts, and the spend model is memoized per process anyway. The
|