bullswarm 0.28.6 → 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 +24 -0
- package/package.json +1 -1
- package/src/cli.js +12 -2
- package/src/help.js +6 -2
- package/src/lib/route.js +356 -12
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
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
|
+
|
|
3
27
|
## 0.28.6 — the 5-hour near-limit line reads the clock
|
|
4
28
|
|
|
5
29
|
- routing: the 5-hour near-limit line is now clock-relative. A pool is
|
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,
|
|
@@ -121,13 +123,21 @@ async function cmdPools(opts) {
|
|
|
121
123
|
? (projectedPct == null ? '' : ` 5h=?->${projectedPct}%${clock}`)
|
|
122
124
|
: ` 5h=${readingPct}%${projectedPct != null && projectedPct !== readingPct ? `->${projectedPct}%` : ''}${clock}`;
|
|
123
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
|
+
: '';
|
|
124
134
|
const status = !p.enabled
|
|
125
135
|
? 'disabled'
|
|
126
136
|
: p.quarantine
|
|
127
137
|
? `QUARANTINED until ${new Date(p.quarantine.until).toLocaleTimeString()} (${p.quarantine.reason})`
|
|
128
138
|
: `ready${burst}${nearLimit}`;
|
|
129
139
|
console.log(
|
|
130
|
-
`${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}`,
|
|
131
141
|
);
|
|
132
142
|
}
|
|
133
143
|
return 0;
|
package/src/help.js
CHANGED
|
@@ -326,11 +326,15 @@ const poolsText = rich({
|
|
|
326
326
|
+ 'nothing is in flight and `5h=<reading>%-><projected>%` when in-flight work is '
|
|
327
327
|
+ 'expected to push the window further; routing decides on the right-hand number. '
|
|
328
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.'
|
|
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.',
|
|
330
334
|
args: [],
|
|
331
335
|
options: [
|
|
332
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)' },
|
|
333
|
-
{ 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' },
|
|
334
338
|
],
|
|
335
339
|
safety: [
|
|
336
340
|
'calls each connector\'s live usage meter (network request per metered pool) to compute used/elapsed percentages',
|
package/src/lib/route.js
CHANGED
|
@@ -55,6 +55,42 @@
|
|
|
55
55
|
// resets_at, an unparsable one, or a reset already in the past means no
|
|
56
56
|
// clock: R7 keeps its fixed line and nothing is clipped, because a pool
|
|
57
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).
|
|
58
94
|
|
|
59
95
|
import { FIVE_HOUR_NEAR_LIMIT_PCT, BURST_BLOCK_PCT, WINDOW_MS } from '../meters/framework.js';
|
|
60
96
|
// One strict numeric coercion for the whole codebase (src/lib/num.js): a
|
|
@@ -75,6 +111,40 @@ export const INCUMBENCY_MARGIN = 10; // surplus points a challenger must beat
|
|
|
75
111
|
*/
|
|
76
112
|
export const DEFAULT_INFLIGHT_PENALTY_PCT = 3;
|
|
77
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
|
+
|
|
78
148
|
export function elapsedPct(meter, now = Date.now()) {
|
|
79
149
|
if (!meter || meter.type === 'none') return 0;
|
|
80
150
|
const start = meter.windowStart ?? 0;
|
|
@@ -297,9 +367,7 @@ export function inflightLoad(pool, opts = {}) {
|
|
|
297
367
|
const count = Math.max(0, num(pool?.inflight?.count) ?? 0);
|
|
298
368
|
// The pacing window's rate, or the weekly one when that window has no
|
|
299
369
|
// measured rate — the surplus and the penalty stay on the same window.
|
|
300
|
-
const paced =
|
|
301
|
-
? pool.spend.pacing
|
|
302
|
-
: pool?.spend?.weekly ?? null;
|
|
370
|
+
const paced = pacingRateBlock(pool);
|
|
303
371
|
const rate = num(paced?.ratePerMinute);
|
|
304
372
|
const minutes = num(candidateMinutes);
|
|
305
373
|
const penaltyPct = num(inflightPenaltyPct) ?? DEFAULT_INFLIGHT_PENALTY_PCT;
|
|
@@ -342,6 +410,201 @@ export function inflightLoad(pool, opts = {}) {
|
|
|
342
410
|
return { count, penalty, ratePerMinute: rate, estimateSource };
|
|
343
411
|
}
|
|
344
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
|
+
|
|
345
608
|
export function isExhausted(pool) {
|
|
346
609
|
// Flat shape (buildPools) first, legacy meter shape second. A stale
|
|
347
610
|
// meterSource reading must not permanently exclude a pool: if the reading
|
|
@@ -423,15 +686,25 @@ export function pickPool(lane, pools, opts = {}) {
|
|
|
423
686
|
const forecast = fiveHourForecast(p, candidateMins, now);
|
|
424
687
|
const load = inflightLoad(p, { candidateMinutes: candidateMins, inflightPenaltyPct });
|
|
425
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
|
+
});
|
|
426
698
|
return {
|
|
427
699
|
pool: p,
|
|
428
700
|
pace,
|
|
429
|
-
|
|
430
|
-
// assignment are expected to spend. Equals pace when nothing is in
|
|
431
|
-
// flight and no rate applies.
|
|
432
|
-
effective: pace - load.penalty,
|
|
701
|
+
effective,
|
|
433
702
|
load,
|
|
434
703
|
forecast,
|
|
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,
|
|
435
708
|
// R8b: R7's tier, applied to the forecast instead of the reading — and
|
|
436
709
|
// R10: only for a pool further through its 5h quota than through its 5h
|
|
437
710
|
// window. A pool at 88% with 23 minutes left keeps its tier 0.
|
|
@@ -442,11 +715,18 @@ export function pickPool(lane, pools, opts = {}) {
|
|
|
442
715
|
gated: forecast.forecasted && forecast.forecast != null && forecast.forecast >= BURST_BLOCK_PCT,
|
|
443
716
|
};
|
|
444
717
|
});
|
|
445
|
-
// R8 before R7 before R2: forecast-gated pools last, then 5h
|
|
446
|
-
//
|
|
447
|
-
//
|
|
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.
|
|
448
722
|
scored.sort(
|
|
449
|
-
(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),
|
|
450
730
|
);
|
|
451
731
|
|
|
452
732
|
const candidates = scored.map((e) => ({
|
|
@@ -472,6 +752,16 @@ export function pickPool(lane, pools, opts = {}) {
|
|
|
472
752
|
estimateSource: e.load.estimateSource,
|
|
473
753
|
nearFiveHourLimit: e.tier === 1,
|
|
474
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,
|
|
475
765
|
}));
|
|
476
766
|
const gatedNames = scored.filter((e) => e.gated).map((e) => e.pool.name);
|
|
477
767
|
const forecastReport = { candidateMinutes: candidateMins, gated: gatedNames };
|
|
@@ -511,6 +801,7 @@ export function pickPool(lane, pools, opts = {}) {
|
|
|
511
801
|
|
|
512
802
|
let winnerEntry;
|
|
513
803
|
let skippedNearLimit = [];
|
|
804
|
+
let skippedDraining = [];
|
|
514
805
|
if (allGated) {
|
|
515
806
|
winnerEntry = [...scored].sort(
|
|
516
807
|
(a, b) =>
|
|
@@ -520,9 +811,21 @@ export function pickPool(lane, pools, opts = {}) {
|
|
|
520
811
|
} else {
|
|
521
812
|
// R7: selection happens only among pools with 5h headroom while any exists.
|
|
522
813
|
const withHeadroom = open.filter((e) => e.tier === 0);
|
|
523
|
-
const
|
|
814
|
+
const headroomSet = withHeadroom.length ? withHeadroom : open;
|
|
524
815
|
skippedNearLimit = withHeadroom.length ? open.filter((e) => e.tier === 1) : [];
|
|
525
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
|
+
|
|
526
829
|
const preferredEntry = preferredPool
|
|
527
830
|
? selectable.find((entry) => entry.pool.name === preferredPool)
|
|
528
831
|
: null;
|
|
@@ -577,6 +880,7 @@ export function pickPool(lane, pools, opts = {}) {
|
|
|
577
880
|
preferred: !allGated && Boolean(preferredPool) && winnerEntry.pool.name === preferredPool,
|
|
578
881
|
effortTier: opts.effortTier,
|
|
579
882
|
skippedNearLimit,
|
|
883
|
+
skippedDraining,
|
|
580
884
|
gated: allGated ? [] : gatedEntries,
|
|
581
885
|
gatedFallback: allGated,
|
|
582
886
|
yieldedBusier,
|
|
@@ -653,6 +957,7 @@ function routingReason(
|
|
|
653
957
|
preferred,
|
|
654
958
|
effortTier,
|
|
655
959
|
skippedNearLimit = [],
|
|
960
|
+
skippedDraining = [],
|
|
656
961
|
gated = [],
|
|
657
962
|
gatedFallback = false,
|
|
658
963
|
yieldedBusier = [],
|
|
@@ -672,6 +977,11 @@ function routingReason(
|
|
|
672
977
|
base = `configured ${effortTier ?? 'effort'} assignment (${
|
|
673
978
|
[winnerEntry.pool.name, note, inflight].filter(Boolean).join(', ')
|
|
674
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(', '));
|
|
675
985
|
} else {
|
|
676
986
|
// Three states, not two (R10): headroom, near the limit and tiered down,
|
|
677
987
|
// or near the limit but under the window's clock — where the note itself
|
|
@@ -697,6 +1007,15 @@ function routingReason(
|
|
|
697
1007
|
`forecast-gated at/above ${BURST_BLOCK_PCT}%: ${gated.map((e) => poolPctLabel(e)).join(', ')}`,
|
|
698
1008
|
);
|
|
699
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
|
+
}
|
|
700
1019
|
if (yieldedBusier.length) {
|
|
701
1020
|
clauses.push(
|
|
702
1021
|
`preferred over busier: ${yieldedBusier
|
|
@@ -707,6 +1026,31 @@ function routingReason(
|
|
|
707
1026
|
return clauses.join(' · ');
|
|
708
1027
|
}
|
|
709
1028
|
|
|
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
|
+
|
|
710
1054
|
/**
|
|
711
1055
|
* `<pool> <pct>%` using the forecast when one exists, else the raw reading —
|
|
712
1056
|
* and, for a pool at/above the near-limit line, where its 5h window stands
|