bullswarm 0.28.5 → 0.28.7
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 +50 -0
- package/package.json +1 -1
- package/src/cli.js +18 -4
- package/src/help.js +8 -2
- package/src/lib/route.js +524 -35
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,55 @@
|
|
|
1
1
|
# bullswarm changelog
|
|
2
2
|
|
|
3
|
+
## 0.28.7 — pools about to reset spend their leftover first
|
|
4
|
+
|
|
5
|
+
- routing: quota that expires sooner is worth more. A pool whose pacing window
|
|
6
|
+
resets within 24 hours (weekly) or 3 days (monthly) is now ranked on urgency
|
|
7
|
+
— its effective surplus divided by the fraction of the window still to run —
|
|
8
|
+
ahead of every pool whose window is not about to close, instead of on the
|
|
9
|
+
surplus alone. At 2026-09-11 12:26 HKT the medium lane went to
|
|
10
|
+
`claude-code:wati` (+22.9 points, 13h33m and 8.1% of its week left) over
|
|
11
|
+
`grok` (+13.8 points, 2h02m and 1.2% left, urgency ~1150 against wati's
|
|
12
|
+
~283), and grok's points expired unspent two hours later; the owner had been
|
|
13
|
+
pinning grok by hand for such runs. Three states for an expiring pool:
|
|
14
|
+
`urgent` (surplus still to spend and a pacing forecast — the reading plus
|
|
15
|
+
in-flight work plus this candidate, each clipped at the reset — below 95%,
|
|
16
|
+
with 5 points of extra headroom demanded when no spend rate was measured),
|
|
17
|
+
which outranks incumbency and a configured effort assignment but never a
|
|
18
|
+
strict pin or the 5-hour rules; `draining` (forecast at/above 95%), ranked
|
|
19
|
+
behind every normal pool so a window about to be emptied is not fed one more
|
|
20
|
+
run; and `normal` (on or ahead of pace), ranked exactly as today. A pool with
|
|
21
|
+
no parsable `paceResetsAt`, a reset already past, or any other window is
|
|
22
|
+
never expiring soon and nothing about it changes. `bullswarm pools` ends an
|
|
23
|
+
expiring pool's line with `resets in 2h02m EXPIRING-SOON urgency=1150`, and
|
|
24
|
+
each routing candidate row carries `paceResetsInMinutes`, `expiringSoon`,
|
|
25
|
+
`urgency`, `forecastPacingPct` and `urgencyState`.
|
|
26
|
+
|
|
27
|
+
## 0.28.6 — the 5-hour near-limit line reads the clock
|
|
28
|
+
|
|
29
|
+
- routing: the 5-hour near-limit line is now clock-relative. A pool is
|
|
30
|
+
deprioritized only when its 5h forecast is at/above 75% AND ahead of the
|
|
31
|
+
share of the 5h window that has already elapsed. On 2026-09-10 at 22:19Z a
|
|
32
|
+
high-tier integrator skipped `claude-code:wati` (81% used, 23 minutes to the
|
|
33
|
+
reset — 92.3% of the window elapsed, 88.1% projected) and
|
|
34
|
+
`claude-code:petsona` (75.3% projected, 85.7% elapsed) and went to the one
|
|
35
|
+
account already ahead of its weekly pace, while wati still held 34% of its
|
|
36
|
+
weekly quota unspent with 13% of the week left to spend it. Both pools now keep the lane:
|
|
37
|
+
88.1% with 23 minutes left is a pool spending at the clock's pace, not a pool
|
|
38
|
+
about to hit a wall. Pools with no `resets_at`, an unparsable one, or a reset
|
|
39
|
+
already past keep the fixed 75% line, and the 90% burst gate is unchanged —
|
|
40
|
+
it ignores the clock. The routing reason and the `candidates[]` rows say
|
|
41
|
+
which case applied: `5h used 81% -> 88.1% projected, under the clock (92.3%
|
|
42
|
+
elapsed)`, `skipped near 5h limit (projected): claude-code:wati 88.1% (20.0%
|
|
43
|
+
elapsed)`, and a new `fiveHourElapsedPct` field. `bullswarm pools` shows the
|
|
44
|
+
same clock: `5h=81% (92% elapsed)`.
|
|
45
|
+
- routing: 5-hour spend is clipped at the reset. Quota spent after the window
|
|
46
|
+
rolls over lands in the next window, so the candidate's minutes and each
|
|
47
|
+
in-flight record's remaining minutes are charged to the 5h forecast only up
|
|
48
|
+
to `fiveHourResetsAt` — 10 minutes from a reset, a 40-minute task at 0.5
|
|
49
|
+
points per minute adds 5 points to the forecast, not 20. The weekly/monthly
|
|
50
|
+
pacing charge is deliberately not clipped: that spend counts against its
|
|
51
|
+
window whichever side of the 5h reset it lands on.
|
|
52
|
+
|
|
3
53
|
## 0.28.5 — narrow-terminal detail panes use the whole screen
|
|
4
54
|
|
|
5
55
|
- tui: on a narrow terminal (under 100 columns, e.g. a phone over SSH) the
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync } from 'node:fs';
|
|
4
4
|
import { join, resolve } from 'node:path';
|
|
5
5
|
import { homedir, tmpdir } from 'node:os';
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
expiringSoonView, fiveHourElapsedPct, formatResetsIn, pickPool,
|
|
8
|
+
} from './lib/route.js';
|
|
7
9
|
import { argvWithModel, watchOnce } from './lib/watch.js';
|
|
8
10
|
import {
|
|
9
11
|
isReasoningLevel, REASONING_DEFAULT, REASONING_LEVELS, resolveReasoningLevel,
|
|
@@ -113,17 +115,29 @@ async function cmdPools(opts) {
|
|
|
113
115
|
const projectedPct = p.projectedFiveHourPct == null
|
|
114
116
|
? null
|
|
115
117
|
: Math.round(p.projectedFiveHourPct * 10) / 10;
|
|
118
|
+
// R10: the same reading means different things at different points in the
|
|
119
|
+
// window, so show where the window stands when the provider reported it.
|
|
120
|
+
const elapsed = fiveHourElapsedPct(p);
|
|
121
|
+
const clock = elapsed == null ? '' : ` (${Math.round(elapsed)}% elapsed)`;
|
|
116
122
|
const fiveHour = readingPct == null
|
|
117
|
-
? (projectedPct == null ? '' : ` 5h=?->${projectedPct}
|
|
118
|
-
: ` 5h=${readingPct}%${projectedPct != null && projectedPct !== readingPct ? `->${projectedPct}%` : ''}`;
|
|
123
|
+
? (projectedPct == null ? '' : ` 5h=?->${projectedPct}%${clock}`)
|
|
124
|
+
: ` 5h=${readingPct}%${projectedPct != null && projectedPct !== readingPct ? `->${projectedPct}%` : ''}${clock}`;
|
|
119
125
|
const nearLimit = p.nearFiveHourLimit === true ? ' NEAR-5H-LIMIT' : '';
|
|
126
|
+
// R11: a pacing window about to reset is quota about to be lost, so say
|
|
127
|
+
// when it closes and how urgent what is left has become. Pools whose
|
|
128
|
+
// window is not closing soon print nothing extra.
|
|
129
|
+
const expiring = expiringSoonView(p, { now });
|
|
130
|
+
const expiringNote = expiring.expiringSoon
|
|
131
|
+
? ` resets in ${formatResetsIn(expiring.minutesToReset)} EXPIRING-SOON`
|
|
132
|
+
+ ` urgency=${Math.round(expiring.urgency)}`
|
|
133
|
+
: '';
|
|
120
134
|
const status = !p.enabled
|
|
121
135
|
? 'disabled'
|
|
122
136
|
: p.quarantine
|
|
123
137
|
? `QUARANTINED until ${new Date(p.quarantine.until).toLocaleTimeString()} (${p.quarantine.reason})`
|
|
124
138
|
: `ready${burst}${nearLimit}`;
|
|
125
139
|
console.log(
|
|
126
|
-
`${p.name.padEnd(14)} cost=${p.costRank} lanes=${p.lanes.join('/')} ${meter} surplus=${p.pace ?? '-'} inflight=${p.inflight?.count ?? 0}${fiveHour} ${status}`,
|
|
140
|
+
`${p.name.padEnd(14)} cost=${p.costRank} lanes=${p.lanes.join('/')} ${meter} surplus=${p.pace ?? '-'} inflight=${p.inflight?.count ?? 0}${fiveHour} ${status}${expiringNote}`,
|
|
127
141
|
);
|
|
128
142
|
}
|
|
129
143
|
return 0;
|
package/src/help.js
CHANGED
|
@@ -324,11 +324,17 @@ const poolsText = rich({
|
|
|
324
324
|
+ 'pace surplus, in-flight assignment count, projected 5-hour utilization, and '
|
|
325
325
|
+ 'quarantine/burst-gate status. The 5-hour column reads `5h=<reading>%` alone when '
|
|
326
326
|
+ 'nothing is in flight and `5h=<reading>%-><projected>%` when in-flight work is '
|
|
327
|
-
+ 'expected to push the window further; routing decides on the right-hand number.'
|
|
327
|
+
+ 'expected to push the window further; routing decides on the right-hand number. '
|
|
328
|
+
+ 'A trailing `(<n>% elapsed)` is how much of that 5-hour window has already run: '
|
|
329
|
+
+ 'routing only deprioritizes a near-limit pool whose usage is ahead of its clock. '
|
|
330
|
+
+ 'A pool whose weekly window resets within 24 hours, or whose monthly window resets '
|
|
331
|
+
+ 'within 3 days, ends its line with `resets in <Nd Nh|Nh Nm|Nm> EXPIRING-SOON '
|
|
332
|
+
+ 'urgency=<n>`: quota about to be lost, scored as surplus divided by the fraction '
|
|
333
|
+
+ 'of the window still to run, which is what routing ranks it on.',
|
|
328
334
|
args: [],
|
|
329
335
|
options: [
|
|
330
336
|
{ 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, monthly, pacing} rates with their source and sample count, pacingWindow, and projectedFiveHourPct / projectedWeeklyPct / projectedMonthlyPct / projectedPacingPct', default: 'human-readable aligned table' },
|
|
337
|
+
{ 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, paceResetsAt, and projectedFiveHourPct / projectedWeeklyPct / projectedMonthlyPct / projectedPacingPct', default: 'human-readable aligned table' },
|
|
332
338
|
],
|
|
333
339
|
safety: [
|
|
334
340
|
'calls each connector\'s live usage meter (network request per metered pool) to compute used/elapsed percentages',
|
package/src/lib/route.js
CHANGED
|
@@ -15,10 +15,12 @@
|
|
|
15
15
|
// R6. A pool at 100% used is exhausted; quarantined pools are ineligible
|
|
16
16
|
// until their quarantine expires (the re-probe path).
|
|
17
17
|
// R7. 5h headroom outranks pace: a pool at/above FIVE_HOUR_NEAR_LIMIT_PCT of
|
|
18
|
-
// its 5h window
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
18
|
+
// its 5h window AND above that window's elapsed share (R10 — the
|
|
19
|
+
// threshold is clock-relative, not a fixed line) is chosen only when no
|
|
20
|
+
// eligible pool below the threshold exists for the lane. Like quarantine
|
|
21
|
+
// and burst gates, this outranks an explicit assignment and incumbency —
|
|
22
|
+
// a near-limit pool that is picked anyway spends the run's next attempt
|
|
23
|
+
// on a quota failure.
|
|
22
24
|
// R8. Route on the FORECAST, not on the reading. A reading is already old at
|
|
23
25
|
// the moment it is read: work dispatched seconds ago has spent quota the
|
|
24
26
|
// meter has not seen, and the assignment being routed will spend more.
|
|
@@ -34,8 +36,63 @@
|
|
|
34
36
|
// R9. Load beats incumbency: an incumbent carrying more in-flight agents
|
|
35
37
|
// than a challenger keeps neither its margin nor its cost guard; the
|
|
36
38
|
// quieter pool wins as soon as its effective surplus is higher.
|
|
39
|
+
// R10. The near-limit line is clock-relative. R7's tier is for a pool that
|
|
40
|
+
// will hit its 5h wall mid-run, and that danger is time-shaped: 88%
|
|
41
|
+
// projected with 23 minutes left in the window is a pool spending at its
|
|
42
|
+
// own pace that is about to be handed a fresh window; 77% with four
|
|
43
|
+
// hours left is a pool heading for the wall. So a pool is deprioritized
|
|
44
|
+
// only when its forecast is at/above FIVE_HOUR_NEAR_LIMIT_PCT *and*
|
|
45
|
+
// above the percentage of the 5h window already elapsed. (Observed
|
|
46
|
+
// 2026-09-10T22:19Z: claude-code:wati, 81% used with 23 minutes left —
|
|
47
|
+
// 92.3% of its window elapsed — was tiered down for a forecast of 88.1%,
|
|
48
|
+
// so a high-tier integrator went to the one account already ahead of its
|
|
49
|
+
// weekly pace while wati's quota, 34% of the week unspent with 13% of
|
|
50
|
+
// the week left, expired unused.) Spend that lands after the reset
|
|
51
|
+
// belongs to the NEXT window: the candidate's minutes and each in-flight
|
|
52
|
+
// record's remaining minutes are clipped at resets_at before they are
|
|
53
|
+
// charged to the 5h forecast — the weekly/monthly pacing penalty is
|
|
54
|
+
// never clipped, that spend does count against its window. No
|
|
55
|
+
// resets_at, an unparsable one, or a reset already in the past means no
|
|
56
|
+
// clock: R7 keeps its fixed line and nothing is clipped, because a pool
|
|
57
|
+
// is never treated differently for a number nobody produced (R8).
|
|
58
|
+
// R11. Quota that expires sooner is worth more ("expiring soon"). A pace
|
|
59
|
+
// surplus is a difference in points and says nothing about how long the
|
|
60
|
+
// pool has left to spend it. (Observed 2026-09-11T04:26Z: grok held
|
|
61
|
+
// +13.8 weekly points with 2h02m left in its week — 1.2% of the window —
|
|
62
|
+
// while claude-code:wati held +22.9 with 13h33m left (8.1%). R2 sent the
|
|
63
|
+
// run to wati on 22.9 > 13.8, and grok's 15 points expired two hours
|
|
64
|
+
// later; the owner had been pinning grok by hand.) So a pool whose
|
|
65
|
+
// PACING window resets within a fixed lead time — EXPIRING_SOON_MS: 24
|
|
66
|
+
// hours weekly, 3 days monthly, the owner's chosen values, about a
|
|
67
|
+
// seventh of a week and a tenth of a month — is ranked on
|
|
68
|
+
// urgency = effective surplus / the fraction of its window still to run
|
|
69
|
+
// (floored at MIN_WINDOW_LEFT_FRACTION so a reset seconds away cannot
|
|
70
|
+
// divide by zero) instead of on the surplus alone. Three states:
|
|
71
|
+
// urgent — surplus still to spend and a pacing forecast (the
|
|
72
|
+
// reading, plus in-flight work and this candidate, each
|
|
73
|
+
// clipped at the pacing reset exactly as R10 clips the 5h
|
|
74
|
+
// window) below PACING_FORECAST_BLOCK_PCT; with no measured
|
|
75
|
+
// rate the reading must also sit 5 points under that line,
|
|
76
|
+
// because an unmeasured pool's forecast is only its
|
|
77
|
+
// reading. Ranks ahead of every pool not expiring soon.
|
|
78
|
+
// draining — forecast at/above the line: ranked after every normal
|
|
79
|
+
// pool and chosen only when nothing else is eligible, so a
|
|
80
|
+
// pool about to be emptied is not fed one more run that
|
|
81
|
+
// would push it over the wall.
|
|
82
|
+
// normal — expiring soon but on or ahead of pace: ranked with
|
|
83
|
+
// everyone else on effective surplus, exactly as today.
|
|
84
|
+
// Urgency outranks incumbency (R3/R4/R9) and a configured effort
|
|
85
|
+
// assignment (preferredPool) by the same mechanism R7's tier uses —
|
|
86
|
+
// selection happens among the urgent pools while one exists — so an
|
|
87
|
+
// urgent challenger needs neither the 10-point margin nor the cost
|
|
88
|
+
// guard. It never overrides a strict pin (workflow strictPool filters
|
|
89
|
+
// the pool list before pickPool ever sees it), and never the 5h rules:
|
|
90
|
+
// a pool tiered down by R7/R10 or gated by R8 is not rescued by
|
|
91
|
+
// urgency. No pacing window, no parsable paceResetsAt, a reset already
|
|
92
|
+
// in the past, or any window other than weekly/monthly means there is
|
|
93
|
+
// no lead time to measure and nothing about the pool changes (R8).
|
|
37
94
|
|
|
38
|
-
import { FIVE_HOUR_NEAR_LIMIT_PCT, BURST_BLOCK_PCT } from '../meters/framework.js';
|
|
95
|
+
import { FIVE_HOUR_NEAR_LIMIT_PCT, BURST_BLOCK_PCT, WINDOW_MS } from '../meters/framework.js';
|
|
39
96
|
// One strict numeric coercion for the whole codebase (src/lib/num.js): a
|
|
40
97
|
// missing measurement stays null instead of becoming a confident zero.
|
|
41
98
|
import { finiteOrNull as num } from './num.js';
|
|
@@ -54,6 +111,40 @@ export const INCUMBENCY_MARGIN = 10; // surplus points a challenger must beat
|
|
|
54
111
|
*/
|
|
55
112
|
export const DEFAULT_INFLIGHT_PENALTY_PCT = 3;
|
|
56
113
|
|
|
114
|
+
/**
|
|
115
|
+
* R11 lead times: how close a pacing window's reset has to be before the pool
|
|
116
|
+
* counts as "expiring soon". The owner's chosen values — roughly a seventh of
|
|
117
|
+
* a week and a tenth of a month — long enough that a run dispatched now can
|
|
118
|
+
* still use the quota, short enough that the pool really is about to lose it.
|
|
119
|
+
* Any window that is not one of these keys is never expiring soon.
|
|
120
|
+
*/
|
|
121
|
+
export const EXPIRING_SOON_MS = {
|
|
122
|
+
weekly: 24 * 3600_000,
|
|
123
|
+
monthly: 72 * 3600_000,
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Pacing-window forecast at/above which an expiring-soon pool is `draining`
|
|
128
|
+
* rather than `urgent`: its window is about to close AND about to be emptied,
|
|
129
|
+
* so one more run spends the run's next attempt on a quota failure.
|
|
130
|
+
*/
|
|
131
|
+
export const PACING_FORECAST_BLOCK_PCT = 95;
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Smallest window-left fraction urgency will divide by (0.5% of the window).
|
|
135
|
+
* A reset thirty seconds away is 0.005% of a week: without a floor the score
|
|
136
|
+
* would be Infinity-shaped and one pool would swallow every lane.
|
|
137
|
+
*/
|
|
138
|
+
export const MIN_WINDOW_LEFT_FRACTION = 0.005;
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Points of headroom an UNMEASURED expiring-soon pool needs below
|
|
142
|
+
* PACING_FORECAST_BLOCK_PCT to be called urgent. With no spend rate its
|
|
143
|
+
* forecast is only its reading plus a flat penalty, so the last few points
|
|
144
|
+
* before the line are exactly where that estimate is least trustworthy.
|
|
145
|
+
*/
|
|
146
|
+
export const UNMEASURED_URGENT_HEADROOM_PCT = 5;
|
|
147
|
+
|
|
57
148
|
export function elapsedPct(meter, now = Date.now()) {
|
|
58
149
|
if (!meter || meter.type === 'none') return 0;
|
|
59
150
|
const start = meter.windowStart ?? 0;
|
|
@@ -100,36 +191,115 @@ function tenth(value) {
|
|
|
100
191
|
return Math.round(Number(value) * 10) / 10;
|
|
101
192
|
}
|
|
102
193
|
|
|
194
|
+
/** The 5h window, in minutes — 300. */
|
|
195
|
+
export const FIVE_HOUR_WINDOW_MINUTES = WINDOW_MS['5h'] / 60_000;
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Minutes left before this pool's 5h window resets, from the provider's
|
|
199
|
+
* `fiveHourResetsAt` (src/lib/config.js, straight off the meter reading).
|
|
200
|
+
*
|
|
201
|
+
* null when there is no reading, when it cannot be parsed, or when the reset
|
|
202
|
+
* is already at/behind `now` — an outrun deadline is unknown, not a
|
|
203
|
+
* zero-length window (R8/R10).
|
|
204
|
+
*/
|
|
205
|
+
export function minutesUntilFiveHourReset(pool, now = Date.now()) {
|
|
206
|
+
const resetsAtMs = Date.parse(pool?.fiveHourResetsAt ?? '');
|
|
207
|
+
if (!Number.isFinite(resetsAtMs)) return null;
|
|
208
|
+
const minutes = (resetsAtMs - now) / 60_000;
|
|
209
|
+
return minutes > 0 ? minutes : null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* How much of the 5h window has already elapsed, 0–100, or null when the
|
|
214
|
+
* reset time is unknown (R10). elapsed = 100 × (300 − minutes left) / 300.
|
|
215
|
+
*/
|
|
216
|
+
export function fiveHourElapsedPct(pool, now = Date.now()) {
|
|
217
|
+
const left = minutesUntilFiveHourReset(pool, now);
|
|
218
|
+
if (left == null) return null;
|
|
219
|
+
const elapsed = (100 * (FIVE_HOUR_WINDOW_MINUTES - left)) / FIVE_HOUR_WINDOW_MINUTES;
|
|
220
|
+
return Math.max(0, Math.min(100, elapsed));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* In-flight minutes that fall PAST the 5h reset, summed over the records the
|
|
225
|
+
* producer already charged to this window (R10). Only these minutes are
|
|
226
|
+
* credited back from the projection — the rest of the record still spends
|
|
227
|
+
* inside the window being forecast.
|
|
228
|
+
*/
|
|
229
|
+
function inflightOverflowMinutes(pool, minutesToReset) {
|
|
230
|
+
const records = Array.isArray(pool?.inflight?.records) ? pool.inflight.records : [];
|
|
231
|
+
let overflow = 0;
|
|
232
|
+
for (const record of records) {
|
|
233
|
+
const m = num(record?.remainingMinutes);
|
|
234
|
+
if (m == null) continue;
|
|
235
|
+
overflow += Math.max(0, Math.max(0, m) - minutesToReset);
|
|
236
|
+
}
|
|
237
|
+
return overflow;
|
|
238
|
+
}
|
|
239
|
+
|
|
103
240
|
/**
|
|
104
241
|
* 5h forecast for one pool and the assignment being routed (R8 rule a):
|
|
105
242
|
*
|
|
106
243
|
* forecast = (projectedFiveHourPct ?? fiveHourUsedPct)
|
|
107
|
-
*
|
|
244
|
+
* − ratePerMinute × in-flight minutes past the reset
|
|
245
|
+
* + ratePerMinute × min(candidateMinutes, minutes to the reset)
|
|
108
246
|
*
|
|
109
247
|
* with the candidate term added only when both of its numbers exist; anything
|
|
110
248
|
* else falls back to the best available reading, and a pool with no reading
|
|
111
249
|
* at all forecasts null (unknown — never gated, never deprioritized).
|
|
112
250
|
*
|
|
251
|
+
* R10 clipping: quota spent after `fiveHourResetsAt` lands in the NEXT 5h
|
|
252
|
+
* window and cannot overflow this one, so the candidate's minutes are clipped
|
|
253
|
+
* at the reset, and the in-flight minutes the producer charged past the reset
|
|
254
|
+
* are credited back out of its projection (never below the pool's own
|
|
255
|
+
* reading). This clip is the 5h forecast's alone — the pacing-window charge in
|
|
256
|
+
* inflightLoad() is deliberately left unclipped, because that spend does count
|
|
257
|
+
* against the weekly/monthly window whichever side of the 5h reset it lands
|
|
258
|
+
* on. With no parsable reset time nothing is clipped at all.
|
|
259
|
+
*
|
|
113
260
|
* `forecasted` records whether the number is more than the raw reading. Only a
|
|
114
261
|
* real projection input (a producer-supplied projectedFiveHourPct, or a rate ×
|
|
115
262
|
* candidateMinutes term) turns a reading into a forecast; a bare reading keeps
|
|
116
263
|
* exactly its old meaning so nothing changes for callers that attach no model.
|
|
117
264
|
*
|
|
265
|
+
* `nearLimit` is the raw R7 test (forecast >= FIVE_HOUR_NEAR_LIMIT_PCT);
|
|
266
|
+
* `underClock` is R10's exemption from it — near the limit, but no further
|
|
267
|
+
* into the window's quota than into the window's time.
|
|
268
|
+
*
|
|
118
269
|
* @param {object} pool
|
|
119
270
|
* @param {number|null} [candidateMinutes] expected minutes of this assignment
|
|
271
|
+
* @param {number} [now]
|
|
120
272
|
* @returns {{raw: number|null, projected: number|null,
|
|
121
273
|
* ratePerMinute: number|null, candidateAdd: number|null,
|
|
122
|
-
* forecast: number|null, forecasted: boolean
|
|
274
|
+
* forecast: number|null, forecasted: boolean,
|
|
275
|
+
* minutesToReset: number|null, elapsedPct: number|null,
|
|
276
|
+
* inflightCreditPct: number, nearLimit: boolean,
|
|
277
|
+
* underClock: boolean}}
|
|
123
278
|
*/
|
|
124
|
-
export function fiveHourForecast(pool, candidateMinutes = null) {
|
|
279
|
+
export function fiveHourForecast(pool, candidateMinutes = null, now = Date.now()) {
|
|
125
280
|
const raw = num(pool?.fiveHourUsedPct);
|
|
126
281
|
const projected = num(pool?.projectedFiveHourPct);
|
|
127
282
|
const ratePerMinute = num(pool?.spend?.fiveHour?.ratePerMinute);
|
|
128
283
|
const minutes = num(candidateMinutes);
|
|
129
|
-
const
|
|
284
|
+
const minutesToReset = minutesUntilFiveHourReset(pool, now);
|
|
285
|
+
const clip = (m) => (minutesToReset == null ? m : Math.min(m, minutesToReset));
|
|
130
286
|
const candidateAdd =
|
|
131
|
-
ratePerMinute != null && minutes != null ? ratePerMinute * minutes : null;
|
|
287
|
+
ratePerMinute != null && minutes != null ? ratePerMinute * clip(minutes) : null;
|
|
288
|
+
// Credit back only what the producer charged past the reset; a zero credit
|
|
289
|
+
// leaves the projection byte-for-byte what it was before R10.
|
|
290
|
+
const overflowMinutes =
|
|
291
|
+
minutesToReset == null || ratePerMinute == null
|
|
292
|
+
? 0
|
|
293
|
+
: inflightOverflowMinutes(pool, minutesToReset);
|
|
294
|
+
const inflightCreditPct = overflowMinutes > 0 ? ratePerMinute * overflowMinutes : 0;
|
|
295
|
+
const base =
|
|
296
|
+
projected == null ? raw
|
|
297
|
+
: inflightCreditPct > 0
|
|
298
|
+
? Math.max(raw ?? projected - inflightCreditPct, projected - inflightCreditPct)
|
|
299
|
+
: projected;
|
|
132
300
|
const forecast = base == null ? null : base + (candidateAdd ?? 0);
|
|
301
|
+
const elapsedPct = fiveHourElapsedPct(pool, now);
|
|
302
|
+
const nearLimit = forecast != null && forecast >= FIVE_HOUR_NEAR_LIMIT_PCT;
|
|
133
303
|
return {
|
|
134
304
|
raw,
|
|
135
305
|
projected,
|
|
@@ -137,6 +307,13 @@ export function fiveHourForecast(pool, candidateMinutes = null) {
|
|
|
137
307
|
candidateAdd,
|
|
138
308
|
forecast,
|
|
139
309
|
forecasted: projected != null || candidateAdd != null,
|
|
310
|
+
minutesToReset,
|
|
311
|
+
elapsedPct,
|
|
312
|
+
inflightCreditPct,
|
|
313
|
+
nearLimit,
|
|
314
|
+
// R10: at/above the line but no further through its quota than through its
|
|
315
|
+
// window — the reset arrives before the wall does.
|
|
316
|
+
underClock: nearLimit && elapsedPct != null && forecast <= elapsedPct,
|
|
140
317
|
};
|
|
141
318
|
}
|
|
142
319
|
|
|
@@ -167,6 +344,12 @@ export function fiveHourForecast(pool, candidateMinutes = null) {
|
|
|
167
344
|
* elapsed worker-minutes (src/lib/assignments.js attachInflight), which says
|
|
168
345
|
* nothing about the quota still to be spent.
|
|
169
346
|
*
|
|
347
|
+
* These minutes are NOT clipped at the 5h reset (R10). This charge is the
|
|
348
|
+
* pacing window's — weekly or monthly — and an agent still running an hour
|
|
349
|
+
* after the 5h window rolls over goes on spending the same weekly quota. Only
|
|
350
|
+
* the 5h forecast in fiveHourForecast() clips at `fiveHourResetsAt`, and it
|
|
351
|
+
* computes that separately from this number.
|
|
352
|
+
*
|
|
170
353
|
* estimateSource: `none` (nothing to charge), `penalty` (the flat floor set the
|
|
171
354
|
* charge), or the source label of the rate that was used (`history` /
|
|
172
355
|
* `bootstrap`, from `spend.pacing` or `spend.weekly`) when the measured
|
|
@@ -184,9 +367,7 @@ export function inflightLoad(pool, opts = {}) {
|
|
|
184
367
|
const count = Math.max(0, num(pool?.inflight?.count) ?? 0);
|
|
185
368
|
// The pacing window's rate, or the weekly one when that window has no
|
|
186
369
|
// measured rate — the surplus and the penalty stay on the same window.
|
|
187
|
-
const paced =
|
|
188
|
-
? pool.spend.pacing
|
|
189
|
-
: pool?.spend?.weekly ?? null;
|
|
370
|
+
const paced = pacingRateBlock(pool);
|
|
190
371
|
const rate = num(paced?.ratePerMinute);
|
|
191
372
|
const minutes = num(candidateMinutes);
|
|
192
373
|
const penaltyPct = num(inflightPenaltyPct) ?? DEFAULT_INFLIGHT_PENALTY_PCT;
|
|
@@ -229,6 +410,201 @@ export function inflightLoad(pool, opts = {}) {
|
|
|
229
410
|
return { count, penalty, ratePerMinute: rate, estimateSource };
|
|
230
411
|
}
|
|
231
412
|
|
|
413
|
+
/**
|
|
414
|
+
* The spend block whose rate paces this pool: `spend.pacing` when it carries a
|
|
415
|
+
* measured rate, else `spend.weekly`. Charging a weekly rate against a monthly
|
|
416
|
+
* surplus would compare points from two different windows, so this is the only
|
|
417
|
+
* fallback — and it is the one inflightLoad() has always used, shared here so
|
|
418
|
+
* the pacing forecast (R11) charges the same rate the ranking charges.
|
|
419
|
+
*/
|
|
420
|
+
function pacingRateBlock(pool) {
|
|
421
|
+
return num(pool?.spend?.pacing?.ratePerMinute) != null
|
|
422
|
+
? pool.spend.pacing
|
|
423
|
+
: pool?.spend?.weekly ?? null;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Minutes until this pool's PACING window (weekly or monthly) resets, from
|
|
428
|
+
* `pool.paceResetsAt` (src/lib/config.js, straight off the meter reading).
|
|
429
|
+
*
|
|
430
|
+
* null when there is no reading, when it cannot be parsed, or when the reset
|
|
431
|
+
* is already at/behind `now` — an outrun deadline is unknown, not a
|
|
432
|
+
* zero-length window (R8/R11). The 5h twin is minutesUntilFiveHourReset().
|
|
433
|
+
*/
|
|
434
|
+
export function minutesUntilPacingReset(pool, now = Date.now()) {
|
|
435
|
+
const resetsAtMs = Date.parse(pool?.paceResetsAt ?? '');
|
|
436
|
+
if (!Number.isFinite(resetsAtMs)) return null;
|
|
437
|
+
const minutes = (resetsAtMs - now) / 60_000;
|
|
438
|
+
return minutes > 0 ? minutes : null;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** `5d22h`, `2h02m`, `45m` — how long a window has left, for humans (R11). */
|
|
442
|
+
export function formatResetsIn(minutes) {
|
|
443
|
+
const total = Math.max(0, Math.round(num(minutes) ?? 0));
|
|
444
|
+
const days = Math.floor(total / 1440);
|
|
445
|
+
const hours = Math.floor((total % 1440) / 60);
|
|
446
|
+
const mins = total % 60;
|
|
447
|
+
if (days > 0) return `${days}d${hours}h`;
|
|
448
|
+
if (hours > 0) return `${hours}h${String(mins).padStart(2, '0')}m`;
|
|
449
|
+
return `${mins}m`;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* In-flight minutes that fall INSIDE the pacing window still to run — the
|
|
454
|
+
* complement of inflightOverflowMinutes(). Everything after the reset is the
|
|
455
|
+
* next window's problem (R11, mirroring R10).
|
|
456
|
+
*/
|
|
457
|
+
function inflightMinutesWithin(pool, minutesToReset) {
|
|
458
|
+
const records = Array.isArray(pool?.inflight?.records) ? pool.inflight.records : [];
|
|
459
|
+
let inside = 0;
|
|
460
|
+
for (const record of records) {
|
|
461
|
+
const m = num(record?.remainingMinutes);
|
|
462
|
+
if (m == null) continue;
|
|
463
|
+
const kept = Math.max(0, m);
|
|
464
|
+
inside += minutesToReset == null ? kept : Math.min(kept, minutesToReset);
|
|
465
|
+
}
|
|
466
|
+
return inside;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* What this pool's PACING window (weekly or monthly) will read once the work
|
|
471
|
+
* it is already carrying and the assignment being routed have landed (R11):
|
|
472
|
+
*
|
|
473
|
+
* forecast = (projectedPacingPct ?? usedPct)
|
|
474
|
+
* − ratePerMinute × in-flight minutes past the pacing reset
|
|
475
|
+
* + ratePerMinute × min(candidateMinutes, minutes to the reset)
|
|
476
|
+
*
|
|
477
|
+
* Spend that lands after the reset belongs to the NEXT window, so both terms
|
|
478
|
+
* are clipped at `paceResetsAt` exactly as fiveHourForecast() clips at the 5h
|
|
479
|
+
* one: the producer's projection (src/lib/spend.js) charges every remaining
|
|
480
|
+
* in-flight minute to this window, and the minutes past the reset are credited
|
|
481
|
+
* back out of it — never below the pool's own reading. When the producer
|
|
482
|
+
* attached no projection the same in-flight minutes are added to the reading
|
|
483
|
+
* instead, which is the identical number by another route.
|
|
484
|
+
*
|
|
485
|
+
* With no measured rate there is nothing to multiply by: the forecast is the
|
|
486
|
+
* reading plus the flat per-agent penalty inflightLoad() already charges, and
|
|
487
|
+
* a pool with no reading at all forecasts null (unknown — R8).
|
|
488
|
+
*
|
|
489
|
+
* @returns {{raw: number|null, projected: number|null,
|
|
490
|
+
* ratePerMinute: number|null, candidateAdd: number|null,
|
|
491
|
+
* inflightCreditPct: number, minutesToReset: number|null,
|
|
492
|
+
* forecast: number|null}}
|
|
493
|
+
*/
|
|
494
|
+
export function pacingForecast(pool, candidateMinutes = null, now = Date.now(), opts = {}) {
|
|
495
|
+
const { inflightPenaltyPct = DEFAULT_INFLIGHT_PENALTY_PCT } = opts;
|
|
496
|
+
const raw = num(pool?.usedPct);
|
|
497
|
+
const projected = num(pool?.projectedPacingPct);
|
|
498
|
+
const ratePerMinute = num(pacingRateBlock(pool)?.ratePerMinute);
|
|
499
|
+
const minutes = num(candidateMinutes);
|
|
500
|
+
const minutesToReset = minutesUntilPacingReset(pool, now);
|
|
501
|
+
const base = projected ?? raw;
|
|
502
|
+
const empty = {
|
|
503
|
+
raw, projected, ratePerMinute, candidateAdd: null, inflightCreditPct: 0, minutesToReset,
|
|
504
|
+
};
|
|
505
|
+
if (base == null) return { ...empty, forecast: null };
|
|
506
|
+
if (ratePerMinute == null) {
|
|
507
|
+
const count = Math.max(0, num(pool?.inflight?.count) ?? 0);
|
|
508
|
+
const penaltyPct = num(inflightPenaltyPct) ?? DEFAULT_INFLIGHT_PENALTY_PCT;
|
|
509
|
+
return { ...empty, forecast: base + count * penaltyPct };
|
|
510
|
+
}
|
|
511
|
+
const clip = (m) => (minutesToReset == null ? m : Math.min(m, minutesToReset));
|
|
512
|
+
const candidateAdd = minutes == null ? 0 : ratePerMinute * clip(Math.max(0, minutes));
|
|
513
|
+
const overflowMinutes =
|
|
514
|
+
minutesToReset == null ? 0 : inflightOverflowMinutes(pool, minutesToReset);
|
|
515
|
+
const inflightCreditPct = overflowMinutes > 0 ? ratePerMinute * overflowMinutes : 0;
|
|
516
|
+
const carried =
|
|
517
|
+
projected == null
|
|
518
|
+
? raw + ratePerMinute * inflightMinutesWithin(pool, minutesToReset)
|
|
519
|
+
: inflightCreditPct > 0
|
|
520
|
+
? Math.max(raw ?? projected - inflightCreditPct, projected - inflightCreditPct)
|
|
521
|
+
: projected;
|
|
522
|
+
return {
|
|
523
|
+
...empty,
|
|
524
|
+
candidateAdd,
|
|
525
|
+
inflightCreditPct,
|
|
526
|
+
forecast: carried + candidateAdd,
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* R11 view of one pool: is its pacing window about to close, how urgent is the
|
|
532
|
+
* quota it still holds, and what will that window read once in-flight work and
|
|
533
|
+
* this candidate land.
|
|
534
|
+
*
|
|
535
|
+
* `effective` is the ranking's own `pace − load.penalty`; pickPool passes the
|
|
536
|
+
* number it already computed, and any other caller (bullswarm pools) lets this
|
|
537
|
+
* recompute it from the pool.
|
|
538
|
+
*
|
|
539
|
+
* `windowLeftFraction` is (100 − elapsedPct) / 100, floored at
|
|
540
|
+
* MIN_WINDOW_LEFT_FRACTION. A pool whose reading carries no elapsedPct has no
|
|
541
|
+
* measured window position, so the fraction is 1 and urgency is just the
|
|
542
|
+
* surplus — never inflated for a number nobody produced (R8).
|
|
543
|
+
*
|
|
544
|
+
* @returns {{expiringSoon: boolean, window: string|null,
|
|
545
|
+
* minutesToReset: number|null, windowLeftFraction: number|null,
|
|
546
|
+
* effective: number|null, urgency: number|null,
|
|
547
|
+
* forecast: number|null, ratePerMinute: number|null,
|
|
548
|
+
* state: 'urgent'|'normal'|'draining'|null}}
|
|
549
|
+
*/
|
|
550
|
+
export function expiringSoonView(pool, opts = {}) {
|
|
551
|
+
const {
|
|
552
|
+
now = Date.now(),
|
|
553
|
+
candidateMinutes = null,
|
|
554
|
+
inflightPenaltyPct = DEFAULT_INFLIGHT_PENALTY_PCT,
|
|
555
|
+
effective = null,
|
|
556
|
+
} = opts;
|
|
557
|
+
const window = pool?.pacingWindow ?? null;
|
|
558
|
+
const leadMs = EXPIRING_SOON_MS[window] ?? null;
|
|
559
|
+
const minutesToReset = minutesUntilPacingReset(pool, now);
|
|
560
|
+
const notSoon = {
|
|
561
|
+
expiringSoon: false,
|
|
562
|
+
window,
|
|
563
|
+
minutesToReset,
|
|
564
|
+
windowLeftFraction: null,
|
|
565
|
+
effective: null,
|
|
566
|
+
urgency: null,
|
|
567
|
+
forecast: null,
|
|
568
|
+
ratePerMinute: null,
|
|
569
|
+
state: null,
|
|
570
|
+
};
|
|
571
|
+
if (leadMs == null || minutesToReset == null || minutesToReset * 60_000 > leadMs) {
|
|
572
|
+
return notSoon;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const eff =
|
|
576
|
+
num(effective)
|
|
577
|
+
?? paceScore(pool, now) - inflightLoad(pool, { candidateMinutes, inflightPenaltyPct }).penalty;
|
|
578
|
+
const elapsed = num(pool?.elapsedPct);
|
|
579
|
+
const windowLeftFraction = Math.max(
|
|
580
|
+
MIN_WINDOW_LEFT_FRACTION,
|
|
581
|
+
elapsed == null ? 1 : (100 - elapsed) / 100,
|
|
582
|
+
);
|
|
583
|
+
const pacing = pacingForecast(pool, candidateMinutes, now, { inflightPenaltyPct });
|
|
584
|
+
const forecast = pacing.forecast;
|
|
585
|
+
const used = num(pool?.usedPct);
|
|
586
|
+
// An unmeasured pool's forecast is its reading: demand real headroom before
|
|
587
|
+
// handing it the lane ahead of everyone else.
|
|
588
|
+
const trusted =
|
|
589
|
+
pacing.ratePerMinute != null ||
|
|
590
|
+
(used != null && used <= PACING_FORECAST_BLOCK_PCT - UNMEASURED_URGENT_HEADROOM_PCT);
|
|
591
|
+
const state =
|
|
592
|
+
forecast != null && forecast >= PACING_FORECAST_BLOCK_PCT ? 'draining'
|
|
593
|
+
: eff > 0 && forecast != null && trusted ? 'urgent'
|
|
594
|
+
: 'normal';
|
|
595
|
+
return {
|
|
596
|
+
expiringSoon: true,
|
|
597
|
+
window,
|
|
598
|
+
minutesToReset,
|
|
599
|
+
windowLeftFraction,
|
|
600
|
+
effective: eff,
|
|
601
|
+
urgency: eff / windowLeftFraction,
|
|
602
|
+
forecast,
|
|
603
|
+
ratePerMinute: pacing.ratePerMinute,
|
|
604
|
+
state,
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
|
|
232
608
|
export function isExhausted(pool) {
|
|
233
609
|
// Flat shape (buildPools) first, legacy meter shape second. A stale
|
|
234
610
|
// meterSource reading must not permanently exclude a pool: if the reading
|
|
@@ -307,31 +683,50 @@ export function pickPool(lane, pools, opts = {}) {
|
|
|
307
683
|
const eligible = laneCapable.filter((p) => p.modelPolicy?.eligible !== false);
|
|
308
684
|
|
|
309
685
|
const scored = eligible.map((p) => {
|
|
310
|
-
const forecast = fiveHourForecast(p, candidateMins);
|
|
686
|
+
const forecast = fiveHourForecast(p, candidateMins, now);
|
|
311
687
|
const load = inflightLoad(p, { candidateMinutes: candidateMins, inflightPenaltyPct });
|
|
312
688
|
const pace = paceScore(p, now);
|
|
689
|
+
// R8c: pace minus the quota this pool's in-flight work and this
|
|
690
|
+
// assignment are expected to spend. Equals pace when nothing is in
|
|
691
|
+
// flight and no rate applies.
|
|
692
|
+
const effective = pace - load.penalty;
|
|
693
|
+
// R11: the same surplus, divided by how much of the pacing window is left
|
|
694
|
+
// to spend it in. All-null for a pool whose window is not about to close.
|
|
695
|
+
const expiring = expiringSoonView(p, {
|
|
696
|
+
now, candidateMinutes: candidateMins, inflightPenaltyPct, effective,
|
|
697
|
+
});
|
|
313
698
|
return {
|
|
314
699
|
pool: p,
|
|
315
700
|
pace,
|
|
316
|
-
|
|
317
|
-
// assignment are expected to spend. Equals pace when nothing is in
|
|
318
|
-
// flight and no rate applies.
|
|
319
|
-
effective: pace - load.penalty,
|
|
701
|
+
effective,
|
|
320
702
|
load,
|
|
321
703
|
forecast,
|
|
322
|
-
|
|
323
|
-
|
|
704
|
+
expiring,
|
|
705
|
+
// urgent first, draining last, everything else in the middle — the tier
|
|
706
|
+
// R11 adds under R7's 5h tier and above the pace comparison.
|
|
707
|
+
urgencyRank: expiring.state === 'urgent' ? 0 : expiring.state === 'draining' ? 2 : 1,
|
|
708
|
+
// R8b: R7's tier, applied to the forecast instead of the reading — and
|
|
709
|
+
// R10: only for a pool further through its 5h quota than through its 5h
|
|
710
|
+
// window. A pool at 88% with 23 minutes left keeps its tier 0.
|
|
711
|
+
tier: forecast.nearLimit && !forecast.underClock ? 1 : 0,
|
|
324
712
|
// A pool is gated only by a FORECAST at/above the burst line — a bare
|
|
325
713
|
// reading keeps its current meaning (dispatch owns that gate), so pools
|
|
326
714
|
// without a spend model behave exactly as before.
|
|
327
715
|
gated: forecast.forecasted && forecast.forecast != null && forecast.forecast >= BURST_BLOCK_PCT,
|
|
328
716
|
};
|
|
329
717
|
});
|
|
330
|
-
// R8 before R7 before R2: forecast-gated pools last, then 5h
|
|
331
|
-
//
|
|
332
|
-
//
|
|
718
|
+
// R8 before R7 before R11 before R2: forecast-gated pools last, then 5h
|
|
719
|
+
// headroom, then urgent < normal < draining, then the group's own score —
|
|
720
|
+
// urgency among the urgent, most-behind-after-load everywhere else. The
|
|
721
|
+
// candidate list is reported in this exact preference order.
|
|
333
722
|
scored.sort(
|
|
334
|
-
(a, b) =>
|
|
723
|
+
(a, b) =>
|
|
724
|
+
(a.gated ? 1 : 0) - (b.gated ? 1 : 0) ||
|
|
725
|
+
a.tier - b.tier ||
|
|
726
|
+
a.urgencyRank - b.urgencyRank ||
|
|
727
|
+
(a.urgencyRank === 0
|
|
728
|
+
? b.expiring.urgency - a.expiring.urgency
|
|
729
|
+
: b.effective - a.effective),
|
|
335
730
|
);
|
|
336
731
|
|
|
337
732
|
const candidates = scored.map((e) => ({
|
|
@@ -345,6 +740,9 @@ export function pickPool(lane, pools, opts = {}) {
|
|
|
345
740
|
fiveHourUsedPct: e.forecast.raw,
|
|
346
741
|
projectedFiveHourPct: e.forecast.projected,
|
|
347
742
|
forecastFiveHourPct: e.forecast.forecast == null ? null : tenth(e.forecast.forecast),
|
|
743
|
+
// R10: how far into the 5h window this reading sits; null when the
|
|
744
|
+
// provider reported no reset time, in which case the fixed line applies.
|
|
745
|
+
fiveHourElapsedPct: e.forecast.elapsedPct == null ? null : tenth(e.forecast.elapsedPct),
|
|
348
746
|
projectedWeeklyPct: num(e.pool.projectedWeeklyPct),
|
|
349
747
|
// The window this pool is paced by, and the projection in it. Equal to
|
|
350
748
|
// the weekly pair for every pool that declares no monthly quota window.
|
|
@@ -354,6 +752,16 @@ export function pickPool(lane, pools, opts = {}) {
|
|
|
354
752
|
estimateSource: e.load.estimateSource,
|
|
355
753
|
nearFiveHourLimit: e.tier === 1,
|
|
356
754
|
forecastGated: e.gated,
|
|
755
|
+
// R11: when the pacing window resets, whether that is close enough to
|
|
756
|
+
// count, and the urgency/forecast that decided the pool's standing. Every
|
|
757
|
+
// field but the first is null for a pool whose window is not about to
|
|
758
|
+
// close — nothing changes for a number nobody produced (R8).
|
|
759
|
+
paceResetsInMinutes:
|
|
760
|
+
e.expiring.minutesToReset == null ? null : tenth(e.expiring.minutesToReset),
|
|
761
|
+
expiringSoon: e.expiring.expiringSoon,
|
|
762
|
+
urgency: e.expiring.urgency == null ? null : tenth(e.expiring.urgency),
|
|
763
|
+
forecastPacingPct: e.expiring.forecast == null ? null : tenth(e.expiring.forecast),
|
|
764
|
+
urgencyState: e.expiring.state,
|
|
357
765
|
}));
|
|
358
766
|
const gatedNames = scored.filter((e) => e.gated).map((e) => e.pool.name);
|
|
359
767
|
const forecastReport = { candidateMinutes: candidateMins, gated: gatedNames };
|
|
@@ -393,6 +801,7 @@ export function pickPool(lane, pools, opts = {}) {
|
|
|
393
801
|
|
|
394
802
|
let winnerEntry;
|
|
395
803
|
let skippedNearLimit = [];
|
|
804
|
+
let skippedDraining = [];
|
|
396
805
|
if (allGated) {
|
|
397
806
|
winnerEntry = [...scored].sort(
|
|
398
807
|
(a, b) =>
|
|
@@ -402,9 +811,21 @@ export function pickPool(lane, pools, opts = {}) {
|
|
|
402
811
|
} else {
|
|
403
812
|
// R7: selection happens only among pools with 5h headroom while any exists.
|
|
404
813
|
const withHeadroom = open.filter((e) => e.tier === 0);
|
|
405
|
-
const
|
|
814
|
+
const headroomSet = withHeadroom.length ? withHeadroom : open;
|
|
406
815
|
skippedNearLimit = withHeadroom.length ? open.filter((e) => e.tier === 1) : [];
|
|
407
816
|
|
|
817
|
+
// R11, by the same mechanism and one rung below it: while any pool's
|
|
818
|
+
// quota is about to expire with room to spend it, that pool is the only
|
|
819
|
+
// selectable one — which is what puts urgency ahead of incumbency and of
|
|
820
|
+
// a configured effort assignment, both of which are resolved inside
|
|
821
|
+
// `selectable` below. A draining pool is the mirror image: out of
|
|
822
|
+
// selection until nothing else is left.
|
|
823
|
+
const urgentSet = headroomSet.filter((e) => e.urgencyRank === 0);
|
|
824
|
+
const notDraining = headroomSet.filter((e) => e.urgencyRank !== 2);
|
|
825
|
+
const selectable =
|
|
826
|
+
urgentSet.length ? urgentSet : notDraining.length ? notDraining : headroomSet;
|
|
827
|
+
skippedDraining = notDraining.length ? headroomSet.filter((e) => e.urgencyRank === 2) : [];
|
|
828
|
+
|
|
408
829
|
const preferredEntry = preferredPool
|
|
409
830
|
? selectable.find((entry) => entry.pool.name === preferredPool)
|
|
410
831
|
: null;
|
|
@@ -459,6 +880,7 @@ export function pickPool(lane, pools, opts = {}) {
|
|
|
459
880
|
preferred: !allGated && Boolean(preferredPool) && winnerEntry.pool.name === preferredPool,
|
|
460
881
|
effortTier: opts.effortTier,
|
|
461
882
|
skippedNearLimit,
|
|
883
|
+
skippedDraining,
|
|
462
884
|
gated: allGated ? [] : gatedEntries,
|
|
463
885
|
gatedFallback: allGated,
|
|
464
886
|
yieldedBusier,
|
|
@@ -535,6 +957,7 @@ function routingReason(
|
|
|
535
957
|
preferred,
|
|
536
958
|
effortTier,
|
|
537
959
|
skippedNearLimit = [],
|
|
960
|
+
skippedDraining = [],
|
|
538
961
|
gated = [],
|
|
539
962
|
gatedFallback = false,
|
|
540
963
|
yieldedBusier = [],
|
|
@@ -554,10 +977,21 @@ function routingReason(
|
|
|
554
977
|
base = `configured ${effortTier ?? 'effort'} assignment (${
|
|
555
978
|
[winnerEntry.pool.name, note, inflight].filter(Boolean).join(', ')
|
|
556
979
|
})`;
|
|
980
|
+
} else if (winnerEntry.urgencyRank === 0) {
|
|
981
|
+
// R11: this pool did not win on the size of its surplus but on how little
|
|
982
|
+
// time is left to spend it, so the reason names the clock, the fraction of
|
|
983
|
+
// the window still to run, and the forecast that kept it out of draining.
|
|
984
|
+
base = urgencyClause(winnerEntry, [note, inflight].filter(Boolean).join(', '));
|
|
557
985
|
} else {
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
986
|
+
// Three states, not two (R10): headroom, near the limit and tiered down,
|
|
987
|
+
// or near the limit but under the window's clock — where the note itself
|
|
988
|
+
// carries the explanation, so the label stays out of its way.
|
|
989
|
+
const standing =
|
|
990
|
+
!note ? ''
|
|
991
|
+
: winnerEntry.tier === 1 ? ' near its 5h limit'
|
|
992
|
+
: winnerEntry.forecast.underClock ? ''
|
|
993
|
+
: ' with 5h headroom';
|
|
994
|
+
base = `most-behind capable pool${standing} (${detail})`;
|
|
561
995
|
}
|
|
562
996
|
const clauses = [base];
|
|
563
997
|
if (skippedNearLimit.length) {
|
|
@@ -573,6 +1007,15 @@ function routingReason(
|
|
|
573
1007
|
`forecast-gated at/above ${BURST_BLOCK_PCT}%: ${gated.map((e) => poolPctLabel(e)).join(', ')}`,
|
|
574
1008
|
);
|
|
575
1009
|
}
|
|
1010
|
+
if (skippedDraining.length) {
|
|
1011
|
+
// R11: a pool whose window is about to close was passed over anyway,
|
|
1012
|
+
// because the run would spend what little it has left through the wall.
|
|
1013
|
+
clauses.push(
|
|
1014
|
+
`expiring but draining (forecast >= ${PACING_FORECAST_BLOCK_PCT}%): ${skippedDraining
|
|
1015
|
+
.map((e) => `${e.pool.name} ${pacingPctText(e)}`)
|
|
1016
|
+
.join(', ')}`,
|
|
1017
|
+
);
|
|
1018
|
+
}
|
|
576
1019
|
if (yieldedBusier.length) {
|
|
577
1020
|
clauses.push(
|
|
578
1021
|
`preferred over busier: ${yieldedBusier
|
|
@@ -583,21 +1026,67 @@ function routingReason(
|
|
|
583
1026
|
return clauses.join(' · ');
|
|
584
1027
|
}
|
|
585
1028
|
|
|
586
|
-
/**
|
|
1029
|
+
/**
|
|
1030
|
+
* R11's reason for an urgent winner:
|
|
1031
|
+
* `expiring soon: grok resets in 2h02m, surplus 13.8 over 1.2% of the week
|
|
1032
|
+
* left → urgency 1140, forecast 91.0%`. Urgency reads as a whole number: at
|
|
1033
|
+
* this scale a tenth of a point is noise, and the candidate row carries the
|
|
1034
|
+
* rounded value for anything that needs it.
|
|
1035
|
+
*/
|
|
1036
|
+
function urgencyClause(entry, detail) {
|
|
1037
|
+
const { minutesToReset, windowLeftFraction, urgency, window } = entry.expiring;
|
|
1038
|
+
const word = window === 'monthly' ? 'month' : 'week';
|
|
1039
|
+
const left = tenth(windowLeftFraction * 100);
|
|
1040
|
+
const tail = detail ? ` (${detail})` : '';
|
|
1041
|
+
return (
|
|
1042
|
+
`expiring soon: ${entry.pool.name} resets in ${formatResetsIn(minutesToReset)}, `
|
|
1043
|
+
+ `surplus ${tenth(entry.effective)} over ${left}% of the ${word} left `
|
|
1044
|
+
+ `→ urgency ${Math.round(urgency)}, forecast ${pacingPctText(entry)}${tail}`
|
|
1045
|
+
);
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
/** `91.0%` — an expiring-soon pool's pacing forecast, or `?%` with no reading. */
|
|
1049
|
+
function pacingPctText(entry) {
|
|
1050
|
+
const pct = entry.expiring.forecast;
|
|
1051
|
+
return pct == null ? '?%' : `${Number(pct).toFixed(1)}%`;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
/**
|
|
1055
|
+
* `<pool> <pct>%` using the forecast when one exists, else the raw reading —
|
|
1056
|
+
* and, for a pool at/above the near-limit line, where its 5h window stands
|
|
1057
|
+
* (R10): `claude-code:wati 88.1% (92.3% elapsed)`. The clock is what decided
|
|
1058
|
+
* the tier, so the number that decided it is named.
|
|
1059
|
+
*/
|
|
587
1060
|
function poolPctLabel(entry) {
|
|
588
1061
|
const pct = entry.forecast.forecasted ? entry.forecast.forecast : entry.forecast.raw;
|
|
589
|
-
|
|
1062
|
+
const clock = entry.forecast.nearLimit ? elapsedText(entry.forecast.elapsedPct) : null;
|
|
1063
|
+
return `${entry.pool.name}${pct == null ? '' : ` ${tenth(pct)}%`}${clock ? ` (${clock})` : ''}`;
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
/** `92.3% elapsed`, or null when the provider reported no 5h reset time. */
|
|
1067
|
+
function elapsedText(elapsedPct) {
|
|
1068
|
+
return elapsedPct == null ? null : `${Number(elapsedPct).toFixed(1)}% elapsed`;
|
|
590
1069
|
}
|
|
591
1070
|
|
|
592
|
-
/**
|
|
1071
|
+
/**
|
|
1072
|
+
* `5h used 30%` or, when a forecast adds to it, `5h used 30% -> 41% projected`.
|
|
1073
|
+
*
|
|
1074
|
+
* A forecast at/above the near-limit line also carries its window's clock —
|
|
1075
|
+
* `, under the clock (92.3% elapsed)` when R10 exempts it from the tier,
|
|
1076
|
+
* `, 20.0% elapsed` when the clock is what put it there.
|
|
1077
|
+
*/
|
|
593
1078
|
function fiveHourNote(entry) {
|
|
594
|
-
const { raw, forecast, forecasted } = entry.forecast;
|
|
1079
|
+
const { raw, forecast, forecasted, nearLimit, underClock, elapsedPct } = entry.forecast;
|
|
1080
|
+
const clock = nearLimit ? elapsedText(elapsedPct) : null;
|
|
1081
|
+
const suffix = clock ? `, ${underClock ? `under the clock (${clock})` : clock}` : '';
|
|
595
1082
|
if (raw == null) {
|
|
596
|
-
return forecasted && forecast != null ? `5h projected ${tenth(forecast)}
|
|
1083
|
+
return forecasted && forecast != null ? `5h projected ${tenth(forecast)}%${suffix}` : null;
|
|
597
1084
|
}
|
|
598
1085
|
const reading = `5h used ${tenth(raw)}%`;
|
|
599
|
-
if (!forecasted || forecast == null || tenth(forecast) === tenth(raw))
|
|
600
|
-
|
|
1086
|
+
if (!forecasted || forecast == null || tenth(forecast) === tenth(raw)) {
|
|
1087
|
+
return `${reading}${suffix}`;
|
|
1088
|
+
}
|
|
1089
|
+
return `${reading} -> ${tenth(forecast)}% projected${suffix}`;
|
|
601
1090
|
}
|
|
602
1091
|
|
|
603
1092
|
/** `2 in flight`, or null when the caller tracks no in-flight work here. */
|